diff --git a/api/main.proto b/api/main.proto index 944aa3c..ac6f7f4 100644 --- a/api/main.proto +++ b/api/main.proto @@ -735,6 +735,9 @@ message UploadFileReq { message UploadFileRsp { string error = 1; string filename = 2; + // Тип загруженного файла: image | pdf | audio (определяется сервером по + // содержимому). Удобен редактору для предзаполнения file_type улики. + string file_type = 3 [json_name = "file_type"]; } message DownloadFileReq { @@ -810,6 +813,10 @@ message Place { message Application { string name = 1; string image = 2; + // Тип файла улики: image | pdf | audio. Значение выводится сервером из + // расширения файла (см. file_storage.FileType); пустое значение возможно + // только для legacy-строк без расширения. + string file_type = 3 [json_name = "file_type"]; } message Door { diff --git a/bin/evening_detective_server b/bin/evening_detective_server index 6b87433..965d93d 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/cmd/evening_detective_server/gateway_test.go b/cmd/evening_detective_server/gateway_test.go index a156b4d..32fdb14 100644 --- a/cmd/evening_detective_server/gateway_test.go +++ b/cmd/evening_detective_server/gateway_test.go @@ -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) - } - }) -} diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index cc61238..55e26bb 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -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), + }) +} diff --git a/cmd/evening_detective_server/main.swagger.json b/cmd/evening_detective_server/main.swagger.json index 5b86754..5025724 100644 --- a/cmd/evening_detective_server/main.swagger.json +++ b/cmd/evening_detective_server/main.swagger.json @@ -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 улики." } } }, diff --git a/go.mod b/go.mod index 2cc43d9..003ebd7 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect - github.com/aws/smithy-go v1.27.1 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect @@ -41,6 +40,7 @@ require ( ) require ( + github.com/aws/smithy-go v1.27.1 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/internal/app/file_service_test.go b/internal/app/file_service_test.go new file mode 100644 index 0000000..ac71fa8 --- /dev/null +++ b/internal/app/file_service_test.go @@ -0,0 +1,191 @@ +package app + +import ( + "context" + "errors" + "testing" + + "evening_detective_server/internal/modules/file_storage" + "evening_detective_server/internal/modules/processor_jwt" + "evening_detective_server/internal/modules/string_tools" + "evening_detective_server/internal/services/file_service" + proto "evening_detective_server/proto" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// fakeStorage — in-memory реализация IFileStorage для app-слоя. +type fakeStorage struct { + files map[string]*file_storage.File + putErr error + getErr error +} + +func newFakeStorage() *fakeStorage { + return &fakeStorage{files: map[string]*file_storage.File{}} +} + +func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error { + if s.putErr != nil { + return s.putErr + } + cp := *f + cp.Data = append([]byte(nil), f.Data...) + s.files[f.Name] = &cp + return nil +} + +func (s *fakeStorage) Get(_ context.Context, name string) (*file_storage.File, error) { + if s.getErr != nil { + return nil, s.getErr + } + f, ok := s.files[name] + if !ok { + return nil, file_storage.ErrFileNotFound + } + return f, nil +} + +func (s *fakeStorage) Delete(_ context.Context, name string) error { + delete(s.files, name) + return nil +} + +func (s *fakeStorage) MimeType(filename string) string { + return file_storage.FileType(filename) +} + +func newTestFileServer(storage *fakeStorage, maxSize int) *server { + if storage == nil { + storage = newFakeStorage() + } + return &server{ + fileService: file_service.NewFileService(storage, string_tools.NewStringTools(), maxSize), + } +} + +func ctxWithClaims(roles ...string) context.Context { + return context.WithValue(context.Background(), "claims", &processor_jwt.JWTClaims{UserID: 1, Roles: roles}) +} + +func pdfData(n int) []byte { + b := make([]byte, n) + copy(b, "%PDF-1.7 ") + for i := 8; i < n; i++ { + b[i] = 'x' + } + return b +} + +func TestUploadFilePermissionDenied(t *testing.T) { + s := newTestFileServer(nil, 1<<20) + _, err := s.UploadFile(ctxWithClaims(), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(16)}) + if status.Code(err) != codes.PermissionDenied { + t.Errorf("code = %v, want PermissionDenied", status.Code(err)) + } +} + +func TestUploadFileValidationInvalidArgument(t *testing.T) { + s := newTestFileServer(nil, 1<<20) + // Имя без расширения. + _, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue", Data: pdfData(16)}) + if status.Code(err) != codes.InvalidArgument { + t.Errorf("code = %v, want InvalidArgument", status.Code(err)) + } +} + +func TestUploadFileTooLargeResourceExhausted(t *testing.T) { + s := newTestFileServer(nil, 1024) + _, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(1025)}) + if status.Code(err) != codes.ResourceExhausted { + t.Errorf("code = %v, want ResourceExhausted", status.Code(err)) + } +} + +func TestUploadFilePutErrorInternal(t *testing.T) { + storage := newFakeStorage() + storage.putErr = errors.New("s3 недоступен") + s := newTestFileServer(storage, 1<<20) + + _, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(16)}) + if status.Code(err) != codes.Internal { + t.Errorf("code = %v, want Internal", status.Code(err)) + } +} + +func TestUploadFileSuccess(t *testing.T) { + s := newTestFileServer(nil, 1<<20) + rsp, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(16)}) + if err != nil { + t.Fatalf("UploadFile: %v", err) + } + if rsp.Filename == "" { + t.Errorf("Filename пустой") + } + if rsp.FileType != "pdf" { + t.Errorf("FileType = %q, want pdf", rsp.FileType) + } + if rsp.Error != "" { + t.Errorf("Error = %q, want пусто", rsp.Error) + } +} + +// mockServerTransportStream — заглушка для grpc.SetHeader в юнит-тестах. +type mockServerTransportStream struct { + grpc.ServerTransportStream + header metadata.MD +} + +func (m *mockServerTransportStream) SetHeader(md metadata.MD) error { + m.header = md + return nil +} + +func TestDownloadFileNotFound(t *testing.T) { + s := newTestFileServer(nil, 1<<20) + _, err := s.DownloadFile(context.Background(), &proto.DownloadFileReq{Filename: "missing.pdf"}) + if status.Code(err) != codes.NotFound { + t.Errorf("code = %v, want NotFound", status.Code(err)) + } +} + +func TestDownloadFileInternal(t *testing.T) { + storage := newFakeStorage() + storage.getErr = errors.New("s3 недоступен") + s := newTestFileServer(storage, 1<<20) + + _, err := s.DownloadFile(context.Background(), &proto.DownloadFileReq{Filename: "clue.pdf"}) + if status.Code(err) != codes.Internal { + t.Errorf("code = %v, want Internal", status.Code(err)) + } +} + +func TestDownloadFileSuccess(t *testing.T) { + storage := newFakeStorage() + storage.files["clue.pdf"] = &file_storage.File{ + Name: "clue.pdf", + Data: []byte("%PDF-1.7 content"), + Mime: "application/pdf", + } + s := newTestFileServer(storage, 1<<20) + + stream := &mockServerTransportStream{} + ctx := grpc.NewContextWithServerTransportStream(context.Background(), stream) + + body, err := s.DownloadFile(ctx, &proto.DownloadFileReq{Filename: "clue.pdf"}) + if err != nil { + t.Fatalf("DownloadFile: %v", err) + } + if string(body.Data) != "%PDF-1.7 content" { + t.Errorf("Data = %q", body.Data) + } + if body.ContentType != "application/pdf" { + t.Errorf("ContentType = %q, want application/pdf", body.ContentType) + } + if got := stream.header.Get("X-Content-Type-Options"); len(got) != 1 || got[0] != "nosniff" { + t.Errorf("X-Content-Type-Options = %v, want nosniff", got) + } +} diff --git a/internal/app/server.go b/internal/app/server.go index ef8f68f..00dfec9 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -312,27 +312,46 @@ func (s *server) GetPermissions(ctx context.Context, req *proto.GetPermissionsRe } func (s *server) UploadFile(ctx context.Context, req *proto.UploadFileReq) (*proto.UploadFileRsp, error) { - filename, err := s.fileService.UploadFile( - ctx, - &file_storage.File{ - Name: req.Filename, - Data: req.Data, - }, - ) + // Файлы (в т.ч. улики) загружают авторы сценариев в редакторе. + claims := ctx.Value("claims").(*processor_jwt.JWTClaims) + if !roles.HasRole(claims, roles.Author) { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + + filename, fileType, err := s.fileService.UploadFile(ctx, req.Filename, req.Data) if err != nil { - return &proto.UploadFileRsp{ - Error: err.Error(), - }, nil + switch { + case errors.Is(err, file_service.ErrFileTooLarge): + return nil, status.Errorf(codes.ResourceExhausted, "%v", err) + case errors.Is(err, file_service.ErrEmptyName), + errors.Is(err, file_service.ErrEmptyData), + errors.Is(err, file_service.ErrInvalidExtension), + errors.Is(err, file_service.ErrInvalidContentType): + return nil, status.Errorf(codes.InvalidArgument, "%v", err) + default: + return nil, status.Errorf(codes.Internal, "%v", err) + } } return &proto.UploadFileRsp{ Filename: filename, + FileType: fileType, }, nil } func (s *server) DownloadFile(ctx context.Context, req *proto.DownloadFileReq) (*httpbody.HttpBody, error) { file, err := s.fileService.DownloadFile(ctx, req.Filename) if err != nil { - return &httpbody.HttpBody{}, nil + switch { + case errors.Is(err, file_storage.ErrFileNotFound): + return nil, status.Errorf(codes.NotFound, "%v", err) + default: + return nil, status.Errorf(codes.Internal, "%v", err) + } + } + // nosniff — при публичном скачивании браузер не должен угадывать + // Content-Type по содержимому (защита от stored-XSS при расхождении). + if err := grpc.SetHeader(ctx, metadata.Pairs("X-Content-Type-Options", "nosniff")); err != nil { + return nil, status.Errorf(codes.Internal, "failed to set response header: %v", err) } return &httpbody.HttpBody{ Data: file.Data, diff --git a/internal/app/story_mapper.go b/internal/app/story_mapper.go index bbac023..8d13ff5 100644 --- a/internal/app/story_mapper.go +++ b/internal/app/story_mapper.go @@ -109,8 +109,9 @@ func mapKeys(o []*storytelling.Key) []*proto.Key { func mapApplication(o *storytelling.Application) *proto.Application { return &proto.Application{ - Name: o.Name, - Image: o.Image, + Name: o.Name, + Image: o.Image, + FileType: o.FileType, } } @@ -167,8 +168,9 @@ func convertKeys(o []*proto.Key) []*storytelling.Key { func convertApplication(o *proto.Application) *storytelling.Application { return &storytelling.Application{ - Name: textFormatter.FormatString(o.Name), - Image: o.Image, + Name: textFormatter.FormatString(o.Name), + Image: o.Image, + FileType: o.FileType, } } diff --git a/internal/modules/file_storage/interface.go b/internal/modules/file_storage/interface.go index 5f7bfc1..69ce899 100644 --- a/internal/modules/file_storage/interface.go +++ b/internal/modules/file_storage/interface.go @@ -1,6 +1,16 @@ package file_storage -import "context" +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "strings" +) + +// ErrFileNotFound — файл не найден в хранилище. Возвращается Get для +// отсутствующих объектов (маппинг smithy-ошибок NoSuchKey/NotFound). +var ErrFileNotFound = errors.New("файл не найден") type File struct { Name string @@ -14,3 +24,32 @@ type IFileStorage interface { Delete(ctx context.Context, filename string) error MimeType(filename string) string } + +// FileType возвращает категорию файла по расширению имени: pdf → "pdf", +// jpg/jpeg/png/gif/webp → "image", mp3/ogg/wav → "audio", иначе "". +// Регистр расширения и query-параметры игнорируются. +// Используется для деривации file_type улик (см. storytelling.Application). +func FileType(filename string) string { + ext := strings.ToLower(extension(filename)) + switch ext { + case ".pdf": + return "pdf" + case ".jpg", ".jpeg", ".png", ".gif", ".webp": + return "image" + case ".mp3", ".ogg", ".wav": + return "audio" + default: + return "" + } +} + +// NewName возвращает случайное имя файла в хранилище (hex-токен 16 байт +// без расширения): исключает коллизии в общем бакете. Расширение добавляет +// вызывающий код. +func NewName() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} diff --git a/internal/modules/file_storage/storage.go b/internal/modules/file_storage/storage.go index 0af5959..61b9aa9 100644 --- a/internal/modules/file_storage/storage.go +++ b/internal/modules/file_storage/storage.go @@ -2,10 +2,14 @@ package file_storage import ( "context" + "errors" "mime" + "net/http" "path/filepath" + "strings" "time" + "github.com/aws/smithy-go" "github.com/kzzan/s3kit" ) @@ -47,19 +51,27 @@ func NewRustFSStorage( } func (s *storage) Put(ctx context.Context, file *File) error { - mime := s.MimeType(file.Name) - return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mime) + return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mimeFor(file)) } func (s *storage) Get(ctx context.Context, filename string) (*File, error) { data, err := s.client.GetObjectBytes(ctx, s.bucket, filename) if err != nil { + // s3kit отдаёт сырые smithy-ошибки: маппим отсутствие объекта + // (NoSuchKey/NotFound) в sentinel для прикладного слоя. + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + switch apiErr.ErrorCode() { + case "NoSuchKey", "NotFound": + return nil, ErrFileNotFound + } + } return nil, err } return &File{ Name: filename, Data: data, - Mime: s.MimeType(filename), + Mime: mimeFromData(data, filename), }, nil } @@ -73,3 +85,36 @@ func (s *storage) MimeType(filename string) string { } return "application/octet-stream" } + +// mimeFor — Content-Type для Put: явный Mime файла приоритетнее вывода из +// расширения, чтобы объект в S3 соответствовал фактическому содержимому. +func mimeFor(f *File) string { + if f.Mime != "" { + return f.Mime + } + return mimeFromData(f.Data, f.Name) +} + +// mimeFromData — Content-Type по содержимому (первые 512 байт) с fallback +// на расширение. text/* (например, HTML) трактуется как octet-stream и +// уходит в fallback: при публичном скачивании не должно отдаваться как +// html с доверенного домена. +func mimeFromData(data []byte, filename string) string { + detected := http.DetectContentType(data) + if strings.HasPrefix(detected, "text/") || detected == "application/octet-stream" { + if mimeType := mime.TypeByExtension(filepath.Ext(filename)); mimeType != "" { + return mimeType + } + return "application/octet-stream" + } + return detected +} + +// extension — расширение имени файла в нижнем регистре, без query/fragment +// (например, "photo.PNG?v=2" → ".png"). +func extension(filename string) string { + if i := strings.IndexAny(filename, "?#"); i >= 0 { + filename = filename[:i] + } + return filepath.Ext(strings.ToLower(filename)) +} diff --git a/internal/modules/file_storage/storage_test.go b/internal/modules/file_storage/storage_test.go new file mode 100644 index 0000000..5b6850a --- /dev/null +++ b/internal/modules/file_storage/storage_test.go @@ -0,0 +1,101 @@ +package file_storage + +import ( + "strings" + "testing" +) + +func TestFileType(t *testing.T) { + cases := []struct { + filename string + want string + }{ + {"clue.pdf", "pdf"}, + {"clue.PDF", "pdf"}, + {"photo.jpg", "image"}, + {"photo.jpeg", "image"}, + {"photo.PNG", "image"}, + {"photo.gif", "image"}, + {"photo.webp", "image"}, + {"audio.mp3", "audio"}, + {"audio.ogg", "audio"}, + {"audio.wav", "audio"}, + {"audio.m4a", ""}, + {"photo.PNG?v=2", "image"}, + {"http://domain/api/files/photo.pdf", "pdf"}, + {"noextension", ""}, + {"", ""}, + } + for _, c := range cases { + if got := FileType(c.filename); got != c.want { + t.Errorf("FileType(%q) = %q, want %q", c.filename, got, c.want) + } + } +} + +func TestNewName(t *testing.T) { + a, err := NewName() + if err != nil { + t.Fatalf("NewName: %v", err) + } + b, err := NewName() + if err != nil { + t.Fatalf("NewName: %v", err) + } + if a == "" || b == "" { + t.Fatalf("NewName вернул пустое имя") + } + if a == b { + t.Errorf("NewName вернул одинаковые имена %q и %q", a, b) + } + // hex-токен 16 байт = 32 символа + if len(a) != 32 { + t.Errorf("длина имени = %d, want 32", len(a)) + } +} + +func TestMimeFor(t *testing.T) { + png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 16)) + + t.Run("explicit Mime wins", func(t *testing.T) { + f := &File{Name: "clue.pdf", Data: png, Mime: "application/pdf"} + if got := mimeFor(f); got != "application/pdf" { + t.Errorf("mimeFor = %q, want application/pdf", got) + } + }) + + t.Run("empty Mime falls back to content sniff", func(t *testing.T) { + f := &File{Name: "clue.bin", Data: png} + if got := mimeFor(f); got != "image/png" { + t.Errorf("mimeFor = %q, want image/png", got) + } + }) + + t.Run("empty Mime and unknown content falls back to extension", func(t *testing.T) { + f := &File{Name: "clue.pdf", Data: []byte("not really a pdf")} + if got := mimeFor(f); got != "application/pdf" { + t.Errorf("mimeFor = %q, want application/pdf по расширению", got) + } + }) +} + +func TestMimeFromData(t *testing.T) { + cases := []struct { + name string + data []byte + filename string + want string + }{ + {"pdf", []byte("%PDF-1.7 fake"), "clue.pdf", "application/pdf"}, + {"png", []byte("\x89PNG\r\n\x1a\nxxx"), "photo.png", "image/png"}, + {"html falls back to extension", []byte("hi"), "photo.png", "image/png"}, + {"html unknown extension", []byte("hi"), "clue.xyz", "application/octet-stream"}, + {"unknown falls back to extension", []byte{0x00, 0x01, 0x02, 0x03}, "clue.pdf", "application/pdf"}, + {"empty data falls back", nil, "clue.pdf", "application/pdf"}, + } + for _, c := range cases { + if got := mimeFromData(c.data, c.filename); got != c.want { + t.Errorf("mimeFromData(%q, %q) = %q, want %q", c.name, c.filename, got, c.want) + } + } +} diff --git a/internal/modules/storytelling/interface.go b/internal/modules/storytelling/interface.go index 07bffb2..0a981b0 100644 --- a/internal/modules/storytelling/interface.go +++ b/internal/modules/storytelling/interface.go @@ -64,6 +64,9 @@ type Application struct { // Картинка Image string `json:"image"` + + // Тип файла улики: image | pdf | audio. + FileType string `json:"file_type,omitempty"` } // Дверь - действие или диалог diff --git a/internal/modules/storytelling/story.go b/internal/modules/storytelling/story.go index ad72c52..f9cd4d4 100644 --- a/internal/modules/storytelling/story.go +++ b/internal/modules/storytelling/story.go @@ -144,8 +144,9 @@ func (s *story) mapPlace( applications = append( applications, &Application{ - Name: s.cleaner.ClearText(application.Name), - Image: application.Image, + Name: s.cleaner.ClearText(application.Name), + Image: application.Image, + FileType: application.FileType, }, ) } diff --git a/internal/modules/storytelling/story_test.go b/internal/modules/storytelling/story_test.go index ad25617..162a367 100644 --- a/internal/modules/storytelling/story_test.go +++ b/internal/modules/storytelling/story_test.go @@ -229,8 +229,9 @@ func Test_story_GetStory(t *testing.T) { Text: "Текст точки.", Applications: []*Application{ { - Name: "Название улики", - Image: "image.png", + Name: "Название улики", + Image: "image.png", + FileType: "image", }, }, }, @@ -245,8 +246,9 @@ func Test_story_GetStory(t *testing.T) { Text: "Текст точки.", Applications: []*Application{ { - Name: "Название улики", - Image: "image.png", + Name: "Название улики", + Image: "image.png", + FileType: "image", }, }, }, diff --git a/internal/repos/application.go b/internal/repos/application.go index 436e360..825616a 100644 --- a/internal/repos/application.go +++ b/internal/repos/application.go @@ -1,7 +1,8 @@ package repos type Application struct { - Name string - Image string - TeamId int + Name string + Image string + TeamId int + FileType string } diff --git a/internal/repos/applications_repo/repo.go b/internal/repos/applications_repo/repo.go index b74a3a4..4790c89 100644 --- a/internal/repos/applications_repo/repo.go +++ b/internal/repos/applications_repo/repo.go @@ -27,15 +27,17 @@ func (r *ApplicationsRepo) AddApplication( teamId int, name string, image string, + fileType string, ) (int, error) { id := 0 err := r.pool.QueryRow( ctx, - `INSERT INTO applications (name, image, team_id) - VALUES ($1, $2, $3) + `INSERT INTO applications (name, image, file_type, team_id) + VALUES ($1, $2, $3, $4) RETURNING id`, name, image, + fileType, teamId, ).Scan(&id) @@ -55,6 +57,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState( `SELECT name, image, + file_type, team_id FROM applications WHERE team_id = ANY($1) and status = $2`, @@ -72,6 +75,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState( err := rows.Scan( &application.Name, &application.Image, + &application.FileType, &application.TeamId, ) if err != nil { diff --git a/internal/services/file_service/service.go b/internal/services/file_service/service.go index 601f56e..5765c3e 100644 --- a/internal/services/file_service/service.go +++ b/internal/services/file_service/service.go @@ -2,34 +2,137 @@ package file_service import ( "context" + "errors" + "fmt" + "net/http" + "path/filepath" + "strings" + "evening_detective_server/internal/modules/file_storage" "evening_detective_server/internal/modules/string_tools" ) +// Ошибки валидации загружаемого файла. Маппятся в gRPC-статусы в app-слое: +// ErrFileTooLarge → ResourceExhausted, остальные → InvalidArgument. +var ( + ErrEmptyName = errors.New("пустое имя файла") + ErrEmptyData = errors.New("пустые данные файла") + ErrInvalidExtension = errors.New("недопустимое расширение файла (разрешены pdf, jpg, jpeg, png, gif, webp, mp3, ogg, wav)") + ErrInvalidContentType = errors.New("содержимое файла не соответствует разрешённым типам (pdf, изображения или аудио)") + ErrFileTooLarge = errors.New("файл слишком большой") +) + +// allowedExtensions — расширения, разрешённые для загрузки: PDF, изображения +// и аудио (аудио-введение сценария загружается тем же эндпоинтом). +var allowedExtensions = map[string]struct{}{ + ".pdf": {}, + ".jpg": {}, + ".jpeg": {}, + ".png": {}, + ".gif": {}, + ".webp": {}, + ".mp3": {}, + ".ogg": {}, + ".wav": {}, +} + type FileService struct { fileStorage file_storage.IFileStorage stringTools string_tools.IStringTools + maxFileSize int } func NewFileService( fileStorage file_storage.IFileStorage, stringTools string_tools.IStringTools, + maxFileSize int, ) *FileService { return &FileService{ fileStorage: fileStorage, stringTools: stringTools, + maxFileSize: maxFileSize, } } +// UploadFile сохраняет файл в хранилище под уникальным именем и возвращает +// сохранённое имя и категорию файла (image|pdf|audio). +// +// Порядок проверок: имя/данные → расширение → base → размер → содержимое. +// Проверка размера — defense-in-depth: фактический лимит запроса +// устанавливается на HTTP-слое (MaxBytesReader) и gRPC-лимитами. func (s *FileService) UploadFile( ctx context.Context, - file *file_storage.File, -) (string, error) { - file.Name = s.stringTools.Transliterate(file.Name) - if err := s.fileStorage.Put(ctx, file); err != nil { - return "", err + filename string, + data []byte, +) (string, string, error) { + if filename == "" { + return "", "", ErrEmptyName + } + if len(data) == 0 { + return "", "", ErrEmptyData + } + + ext := strings.ToLower(filepath.Ext(filename)) + if _, ok := allowedExtensions[ext]; !ok { + return "", "", ErrInvalidExtension + } + + base := strings.TrimSuffix(filename, filepath.Ext(filename)) + if base == "" { + return "", "", ErrEmptyName + } + + // Транслитерация может обнулить base (например, «!!!.pdf»): хранимое + // имя без base недопустимо — проверяем до размера и содержимого. + transliterated := s.stringTools.Transliterate(base) + if transliterated == "" { + return "", "", ErrEmptyName + } + + if len(data) > s.maxFileSize { + return "", "", fmt.Errorf("%w (лимит %d МБ)", ErrFileTooLarge, s.maxFileSize>>20) + } + + // Категория по содержимому (сырой sniff): text/* и octet-stream не + // проходят вообще, расширение должно соответствовать категории. + sniffed := http.DetectContentType(data) + fileType := contentTypeCategory(sniffed) + if fileType == "" || fileType != file_storage.FileType(filename) { + return "", "", ErrInvalidContentType + } + + randomName, err := file_storage.NewName() + if err != nil { + return "", "", fmt.Errorf("не удалось сгенерировать имя файла: %w", err) + } + storedName := transliterated + "_" + randomName + ext + + if err := s.fileStorage.Put(ctx, &file_storage.File{ + Name: storedName, + Data: data, + Mime: sniffed, + }); err != nil { + return "", "", err + } + return storedName, fileType, nil +} + +// contentTypeCategory возвращает категорию по sniffed-типу: +// application/pdf → pdf; image/* → image; audio/* и application/ogg → audio; +// всё остальное (text/*, application/octet-stream и пр.) → "". +func contentTypeCategory(sniffed string) string { + switch { + case sniffed == "application/pdf": + return "pdf" + case strings.HasPrefix(sniffed, "image/"): + return "image" + case strings.HasPrefix(sniffed, "audio/"): + return "audio" + case sniffed == "application/ogg": + return "audio" + default: + return "" } - return file.Name, nil } func (s *FileService) DownloadFile( diff --git a/internal/services/file_service/service_test.go b/internal/services/file_service/service_test.go new file mode 100644 index 0000000..258d4e5 --- /dev/null +++ b/internal/services/file_service/service_test.go @@ -0,0 +1,164 @@ +package file_service + +import ( + "context" + "errors" + "strings" + "testing" + + "evening_detective_server/internal/modules/file_storage" + "evening_detective_server/internal/modules/string_tools" +) + +// fakeStorage — in-memory реализация IFileStorage для тестов. +type fakeStorage struct { + files map[string]*file_storage.File +} + +func newFakeStorage() *fakeStorage { + return &fakeStorage{files: map[string]*file_storage.File{}} +} + +func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error { + cp := *f + cp.Data = append([]byte(nil), f.Data...) + s.files[f.Name] = &cp + return nil +} + +func (s *fakeStorage) Get(_ context.Context, name string) (*file_storage.File, error) { + f, ok := s.files[name] + if !ok { + return nil, file_storage.ErrFileNotFound + } + return f, nil +} + +func (s *fakeStorage) Delete(_ context.Context, name string) error { + delete(s.files, name) + return nil +} + +func (s *fakeStorage) MimeType(filename string) string { + return file_storage.FileType(filename) +} + +func newService(maxFileSize int) *FileService { + return NewFileService(newFakeStorage(), string_tools.NewStringTools(), maxFileSize) +} + +func mustUpload(t *testing.T, svc *FileService, filename string, data []byte) (string, string) { + t.Helper() + name, fileType, err := svc.UploadFile(context.Background(), filename, data) + if err != nil { + t.Fatalf("UploadFile(%q): %v", filename, err) + } + return name, fileType +} + +func TestUploadFileValid(t *testing.T) { + svc := newService(1 << 20) + + cases := []struct { + filename string + data []byte + wantType string + wantExt string + wantMime string + }{ + {"улика.pdf", []byte("%PDF-1.7 content"), "pdf", ".pdf", "application/pdf"}, + {"photo.png", []byte("\x89PNG\r\n\x1a\ncontent"), "image", ".png", "image/png"}, + {"photo.PNG", []byte("\x89PNG\r\n\x1a\ncontent"), "image", ".png", "image/png"}, + {"photo.jpg", []byte("\xff\xd8\xff\xe0jpeg-content"), "image", ".jpg", "image/jpeg"}, + {"audio.mp3", []byte("ID3\x04\x00\x00\x00\x00\x00\x00audio"), "audio", ".mp3", "audio/mpeg"}, + {"audio.ogg", []byte("OggS\x00\x02audio"), "audio", ".ogg", "application/ogg"}, + {"audio.wav", []byte("RIFF\x24\x00\x00\x00WAVEfmt "), "audio", ".wav", "audio/wave"}, + } + for _, c := range cases { + name, fileType := mustUpload(t, svc, c.filename, c.data) + if fileType != c.wantType { + t.Errorf("%s: fileType = %q, want %q", c.filename, fileType, c.wantType) + } + if !strings.HasSuffix(name, c.wantExt) { + t.Errorf("%s: имя %q не оканчивается на %q", c.filename, name, c.wantExt) + } + if !strings.Contains(name, "_") { + t.Errorf("%s: имя %q не содержит уникальный hex-суффикс", c.filename, name) + } + // Сохранённый MIME соответствует содержимому (sniffed-тип). + stored, err := svc.fileStorage.Get(context.Background(), name) + if err != nil { + t.Fatalf("Get(%q): %v", name, err) + } + if stored.Mime != c.wantMime { + t.Errorf("%s: MIME = %q, want %q", c.filename, stored.Mime, c.wantMime) + } + } +} + +func TestUploadFileUniqueNames(t *testing.T) { + svc := newService(1 << 20) + data := []byte("%PDF-1.7 same content") + + first, _ := mustUpload(t, svc, "clue.pdf", data) + second, _ := mustUpload(t, svc, "clue.pdf", data) + + if first == second { + t.Errorf("одинаковые имена %q — коллизия в бакете", first) + } +} + +func TestUploadFileValidation(t *testing.T) { + svc := newService(1 << 20) + pdf := []byte("%PDF-1.7 content") + + cases := []struct { + name string + filename string + data []byte + wantErr error + }{ + {"пустое имя", "", pdf, ErrEmptyName}, + {"пустые данные", "clue.pdf", nil, ErrEmptyData}, + {"нет расширения", "clue", pdf, ErrInvalidExtension}, + {"недопустимое расширение", "clue.exe", []byte("MZ\x90\x00"), ErrInvalidExtension}, + {"пустой base (.pdf)", ".pdf", pdf, ErrEmptyName}, + {"base из не-латиницы (!!!.pdf)", "!!!.pdf", pdf, ErrEmptyName}, + {"html под видом png", "photo.png", []byte("hi"), ErrInvalidContentType}, + {"octet-stream", "clue.pdf", []byte{0x00, 0x01, 0x02, 0x03}, ErrInvalidContentType}, + {"mismatch pdf+png", "clue.pdf", []byte("\x89PNG\r\n\x1a\ncontent"), ErrInvalidContentType}, + } + for _, c := range cases { + _, _, err := svc.UploadFile(context.Background(), c.filename, c.data) + if !errors.Is(err, c.wantErr) { + t.Errorf("%s: err = %v, want %v", c.name, err, c.wantErr) + } + } +} + +func TestUploadFileTooLarge(t *testing.T) { + svc := newService(1024) + + // ровно лимит — проходит + if _, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1024()); err != nil { + t.Errorf("ровно лимит: err = %v, want nil", err) + } + // больше лимита — ErrFileTooLarge + _, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1025()) + if !errors.Is(err, ErrFileTooLarge) { + t.Errorf("больше лимита: err = %v, want ErrFileTooLarge", err) + } +} + +func bytes1024() []byte { + b := make([]byte, 1024) + copy(b, "%PDF-1.7 ") + for i := 8; i < len(b); i++ { + b[i] = 'x' + } + return b +} + +func bytes1025() []byte { + return append(bytes1024(), 'y') +} diff --git a/internal/services/game_service/application.go b/internal/services/game_service/application.go deleted file mode 100644 index 23fd842..0000000 --- a/internal/services/game_service/application.go +++ /dev/null @@ -1,6 +0,0 @@ -package game_service - -type Application struct { - Name string - Image string -} diff --git a/internal/services/game_service/application_mapper.go b/internal/services/game_service/application_mapper.go index 9eeaf53..1aec642 100644 --- a/internal/services/game_service/application_mapper.go +++ b/internal/services/game_service/application_mapper.go @@ -1,21 +1,64 @@ package game_service import ( + "strings" + + "evening_detective_server/internal/modules/file_storage" "evening_detective_server/internal/modules/storytelling" "evening_detective_server/internal/repos" ) -func mapApplications(o []*repos.Application) []*storytelling.Application { +// mapApplications — улики команды для ответа организатору (GetFullGame): +// имена файлов префиксуются доменом идемпотентно, file_type деривируется +// из расширения при пустом значении (легаси-строки таблицы). +func mapApplications(o []*repos.Application, domain string) []*storytelling.Application { res := make([]*storytelling.Application, 0, len(o)) for _, item := range o { - res = append(res, mapApplication(item)) + res = append(res, mapApplication(item, domain)) } return res } -func mapApplication(o *repos.Application) *storytelling.Application { - return &storytelling.Application{ +func mapApplication(o *repos.Application, domain string) *storytelling.Application { + image := prefixDomain(o.Image, domain) + app := &storytelling.Application{ Name: o.Name, - Image: o.Image, + Image: image, + } + app.FileType = o.FileType + if app.FileType == "" { + app.FileType = file_storage.FileType(image) + } + return app +} + +// newApplication — улика для сохранения команде (AddTeamAction): имена +// приходят из истории уже с доменным префиксом (mapStory), в БД хранятся +// относительные пути — префикс снимается идемпотентно; file_type +// деривируется при пустом значении. Name не меняется (очистка — в +// вызывающем коде, cleaner'ом). +func newApplication(app *storytelling.Application, domain string) *repos.Application { + image := strings.TrimPrefix(app.Image, domain) + fileType := app.FileType + if fileType == "" { + fileType = file_storage.FileType(image) + } + return &repos.Application{ + Name: app.Name, + Image: image, + FileType: fileType, } } + +// prefixDomain — идемпотентная префиксация ссылки доменом хранилища +// (пустые ссылки и внешние http/https URL не трогаются). +func prefixDomain(ref, domain string) string { + if ref == "" || strings.HasPrefix(ref, domain) { + return ref + } + low := strings.ToLower(ref) + if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") { + return ref + } + return domain + ref +} diff --git a/internal/services/game_service/application_mapper_test.go b/internal/services/game_service/application_mapper_test.go new file mode 100644 index 0000000..4727d9d --- /dev/null +++ b/internal/services/game_service/application_mapper_test.go @@ -0,0 +1,117 @@ +package game_service + +import ( + "testing" + + "evening_detective_server/internal/modules/storytelling" + "evening_detective_server/internal/repos" +) + +const testDomain = "http://storage.test/api/files/" + +func TestNewApplication(t *testing.T) { + cases := []struct { + name string + app *storytelling.Application + image string // ожидаемое хранимое имя (без домена) + ftype string // ожидаемый file_type + }{ + { + name: "доменный префикс снимается, file_type сохраняется", + app: &storytelling.Application{ + Name: "Улика", + Image: testDomain + "clue.pdf", + FileType: "pdf", + }, + image: "clue.pdf", + ftype: "pdf", + }, + { + name: "относительное имя не трогается", + app: &storytelling.Application{ + Name: "Улика", + Image: "clue.png", + FileType: "image", + }, + image: "clue.png", + ftype: "image", + }, + { + name: "пустой file_type деривируется из расширения", + app: &storytelling.Application{ + Name: "Улика", + Image: testDomain + "clue.ogg", + }, + image: "clue.ogg", + ftype: "audio", + }, + { + name: "внешний URL снимается как префикс только при совпадении домена", + app: &storytelling.Application{ + Name: "Улика", + Image: "https://example.com/clue.pdf", + }, + image: "https://example.com/clue.pdf", + ftype: "pdf", + }, + } + for _, c := range cases { + got := newApplication(c.app, testDomain) + if got.Name != c.app.Name { + t.Errorf("%s: Name = %q, want %q", c.name, got.Name, c.app.Name) + } + if got.Image != c.image { + t.Errorf("%s: Image = %q, want %q", c.name, got.Image, c.image) + } + if got.FileType != c.ftype { + t.Errorf("%s: FileType = %q, want %q", c.name, got.FileType, c.ftype) + } + } +} + +func TestMapApplications(t *testing.T) { + o := []*repos.Application{ + {Name: "Улика", Image: "clue.pdf", FileType: ""}, + {Name: "Фото", Image: "https://external.example.com/pic.jpg", FileType: "image"}, + } + + got := mapApplications(o, testDomain) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + + // Относительное имя префиксуется доменом, file_type деривируется. + if got[0].Image != testDomain+"clue.pdf" { + t.Errorf("Image[0] = %q, want %q", got[0].Image, testDomain+"clue.pdf") + } + if got[0].FileType != "pdf" { + t.Errorf("FileType[0] = %q, want pdf", got[0].FileType) + } + + // Внешний URL не префиксуется повторно. + if got[1].Image != "https://external.example.com/pic.jpg" { + t.Errorf("Image[1] = %q, want внешний URL без изменений", got[1].Image) + } + if got[1].FileType != "image" { + t.Errorf("FileType[1] = %q, want image", got[1].FileType) + } +} + +func TestPrefixDomain(t *testing.T) { + cases := []struct { + ref string + domain string + want string + }{ + {"clue.pdf", testDomain, testDomain + "clue.pdf"}, + {testDomain + "clue.pdf", testDomain, testDomain + "clue.pdf"}, + {"https://example.com/clue.pdf", testDomain, "https://example.com/clue.pdf"}, + {"http://example.com/clue.pdf", testDomain, "http://example.com/clue.pdf"}, + {"", testDomain, ""}, + } + for _, c := range cases { + if got := prefixDomain(c.ref, c.domain); got != c.want { + t.Errorf("prefixDomain(%q) = %q, want %q", c.ref, got, c.want) + } + } +} diff --git a/internal/services/game_service/service.go b/internal/services/game_service/service.go index bfff6f9..afd6158 100644 --- a/internal/services/game_service/service.go +++ b/internal/services/game_service/service.go @@ -114,7 +114,7 @@ func (s *GameService) GetFullGame( for _, team := range res.Teams { team.ActionsCount = actionsCountByTeamIDs[team.ID] - team.Applications = mapApplications(needApplicationsByTeamIDs[team.ID]) + team.Applications = mapApplications(needApplicationsByTeamIDs[team.ID], s.domain) } return res, nil @@ -263,8 +263,9 @@ func (s *GameService) AddTeamAction(ctx context.Context, teamId int, password st lastPlace := teamStory.Places[len(teamStory.Places)-1] for _, application := range lastPlace.Applications { - _, err := s.applicationsRepo.AddApplication(ctx, teamId, s.cleaner.ClearText(application.Name), application.Image) - if err != nil { + app := newApplication(application, s.domain) + app.Name = s.cleaner.ClearText(app.Name) + if _, err := s.applicationsRepo.AddApplication(ctx, teamId, app.Name, app.Image, app.FileType); err != nil { return err } } diff --git a/internal/services/scenarios_service/scenario_mapper.go b/internal/services/scenarios_service/scenario_mapper.go index bad11b0..4076666 100644 --- a/internal/services/scenarios_service/scenario_mapper.go +++ b/internal/services/scenarios_service/scenario_mapper.go @@ -2,6 +2,9 @@ package scenarios_service import ( "encoding/json" + "strings" + + "evening_detective_server/internal/modules/file_storage" "evening_detective_server/internal/modules/storytelling" "evening_detective_server/internal/repos" ) @@ -73,16 +76,34 @@ func mapStory(o string, domain string) (*storytelling.Story, error) { return nil, err } for _, place := range res.Places { - if place.Image != "" { - place.Image = domain + place.Image + place.Image = prefixDomain(place.Image, domain) + for _, application := range place.Applications { + application.Image = prefixDomain(application.Image, domain) + if application.FileType == "" { + application.FileType = file_storage.FileType(application.Image) + } } } - if res.Introduction != nil && res.Introduction.Audio != "" { - res.Introduction.Audio = domain + res.Introduction.Audio + if res.Introduction != nil { + res.Introduction.Audio = prefixDomain(res.Introduction.Audio, domain) } return res, nil } +// prefixDomain — идемпотентная префиксация ссылки доменом хранилища: +// пустые ссылки и внешние http/https URL не трогаются, повторный префикс +// исключён. Единое правило для картинок точек, аудио введения и улик. +func prefixDomain(ref, domain string) string { + if ref == "" || strings.HasPrefix(ref, domain) { + return ref + } + low := strings.ToLower(ref) + if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") { + return ref + } + return domain + ref +} + func convertStory(o *storytelling.Story) (string, error) { b, err := json.Marshal(o) if err != nil { diff --git a/internal/services/scenarios_service/scenario_mapper_test.go b/internal/services/scenarios_service/scenario_mapper_test.go new file mode 100644 index 0000000..5bc75fc --- /dev/null +++ b/internal/services/scenarios_service/scenario_mapper_test.go @@ -0,0 +1,164 @@ +package scenarios_service + +import ( + "context" + "testing" + + "evening_detective_server/internal/modules/storytelling" +) + +// TestMapStoryPrefixesEvidence — улики получают полный URL (домен + имя), +// как картинки точек и аудио введения; префиксация идемпотентна, внешние +// URL не трогаются; file_type деривируется при пустом значении. +func TestMapStoryPrefixesEvidence(t *testing.T) { + storyJSON := `{ + "introduction": {"text": "Вступление", "audio": "intro.mp3"}, + "places": [{ + "code": "p1", + "name": "Место", + "text": "Текст", + "image": "place.png", + "applications": [ + {"name": "Договор", "image": "contract.pdf"}, + {"name": "Фото", "image": "https://external.example.com/pic.jpg", "file_type": "image"} + ] + }] + }` + + story, err := mapStory(storyJSON, testDomain) + if err != nil { + t.Fatalf("mapStory: %v", err) + } + + if story.Introduction.Audio != testDomain+"intro.mp3" { + t.Errorf("Audio = %q, want %q", story.Introduction.Audio, testDomain+"intro.mp3") + } + place := story.Places[0] + if place.Image != testDomain+"place.png" { + t.Errorf("place.Image = %q, want %q", place.Image, testDomain+"place.png") + } + + // Улика из хранилища: полный URL + деривированный file_type. + app := place.Applications[0] + if app.Image != testDomain+"contract.pdf" { + t.Errorf("application.Image = %q, want %q", app.Image, testDomain+"contract.pdf") + } + if app.FileType != "pdf" { + t.Errorf("application.FileType = %q, want pdf", app.FileType) + } + + // Внешний URL не префиксуется, file_type сохраняется. + external := place.Applications[1] + if external.Image != "https://external.example.com/pic.jpg" { + t.Errorf("external.Image = %q, want без изменений", external.Image) + } + if external.FileType != "image" { + t.Errorf("external.FileType = %q, want image", external.FileType) + } + + // Идемпотентность: повторный прогон не даёт двойного префикса. + jsonOut, err := convertStory(story) + if err != nil { + t.Fatalf("convertStory: %v", err) + } + again, err := mapStory(jsonOut, testDomain) + if err != nil { + t.Fatalf("mapStory повторно: %v", err) + } + if again.Places[0].Applications[0].Image != testDomain+"contract.pdf" { + t.Errorf("повторный mapStory: Image = %q, want %q", again.Places[0].Applications[0].Image, testDomain+"contract.pdf") + } +} + +// TestNormalizeFileType — невалидный file_type заменяется деривацией из +// расширения файла, известные значения сохраняются. +func TestNormalizeFileType(t *testing.T) { + cases := []struct { + fileType string + image string + want string + }{ + {"banana", "http://domain/api/files/clue.pdf", "pdf"}, + {"banana", "clue", ""}, + {"", "clue.png", "image"}, + {"pdf", "clue.pdf", "pdf"}, + {"image", "clue.gif", "image"}, + {"audio", "clue.ogg", "audio"}, + } + for _, c := range cases { + if got := normalizeFileType(c.fileType, c.image); got != c.want { + t.Errorf("normalizeFileType(%q, %q) = %q, want %q", c.fileType, c.image, got, c.want) + } + } +} + +// TestNormalizeStoryBanana — файл-улика с невалидным file_type проходит +// normalizeStory и получает деривированный тип. +func TestNormalizeStoryBanana(t *testing.T) { + story := &storytelling.Story{ + Places: []*storytelling.Place{ + { + Code: "p1", + Name: "Место", + Applications: []*storytelling.Application{ + {Name: "Договор", Image: "clue.pdf", FileType: "banana"}, + }, + }, + }, + } + + jsonOut, err := normalizeStory(story) + if err != nil { + t.Fatalf("normalizeStory: %v", err) + } + + parsed, err := mapStory(jsonOut, "") + if err != nil { + t.Fatalf("mapStory: %v", err) + } + if got := parsed.Places[0].Applications[0].FileType; got != "pdf" { + t.Errorf("FileType = %q, want pdf", got) + } +} + +// TestUpdateStoryTrimsEvidenceDomain — сохранение истории снимает доменный +// префикс с улик (в БД хранятся относительные имена). +func TestUpdateStoryTrimsEvidenceDomain(t *testing.T) { + repo := newFakeScenariosRepo() + storage := newFakeStorage() + svc := newTestService(repo, storage) + + // Сценарий с историей, где улика уже с доменным префиксом. + scenario := seedScenario(t, repo, storage) + scenario.Scenario = `{"places":[{"code":"p1","name":"Место","text":"","image":"place.png","applications":[{"name":"Договор","image":"http://storage.test/api/files/contract.pdf","file_type":"pdf"}]}]}` + + story := &storytelling.Story{ + Places: []*storytelling.Place{ + { + Code: "p1", + Name: "Место", + Image: testDomain + "place.png", + Text: "Текст", + Applications: []*storytelling.Application{ + {Name: "Договор", Image: testDomain + "contract.pdf", FileType: "pdf"}, + }, + }, + }, + } + + if err := svc.updateStory(context.Background(), scenario.ID, story); err != nil { + t.Fatalf("updateStory: %v", err) + } + + parsed, err := mapStory(scenario.Scenario, testDomain) + if err != nil { + t.Fatalf("mapStory: %v", err) + } + app := parsed.Places[0].Applications[0] + if app.Image != testDomain+"contract.pdf" { + t.Errorf("после trim+prefix Image = %q, want %q (без двойного префикса)", app.Image, testDomain+"contract.pdf") + } + if app.FileType != "pdf" { + t.Errorf("FileType = %q, want pdf", app.FileType) + } +} diff --git a/internal/services/scenarios_service/service.go b/internal/services/scenarios_service/service.go index 4df06df..6f3f440 100644 --- a/internal/services/scenarios_service/service.go +++ b/internal/services/scenarios_service/service.go @@ -2,8 +2,6 @@ package scenarios_service import ( "context" - "crypto/rand" - "encoding/hex" "errors" "evening_detective_server/internal/modules/cleaner" "evening_detective_server/internal/modules/file_storage" @@ -373,10 +371,11 @@ func (s *ScenarioService) UploadArchive( if !exists { return "", fmt.Errorf("изображение %q не найдено в архиве", archiveRef) } - name, err := newStorageName(archiveRef) + name, err := file_storage.NewName() if err != nil { return "", err } + name += strings.ToLower(filepath.Ext(archiveRef)) if err := s.fileStorage.Put(ctx, &file_storage.File{ Name: name, Data: content, @@ -433,20 +432,13 @@ func (s *ScenarioService) UploadArchive( return id, nil } -// newStorageName — случайное имя в хранилище (hex + расширение): исключает -// коллизии с файлами других сценариев в общем бакете. -func newStorageName(archivePath string) (string, error) { - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - return "", fmt.Errorf("не удалось сгенерировать имя файла: %w", err) - } - ext := strings.ToLower(filepath.Ext(archivePath)) - return hex.EncodeToString(b[:]) + ext, nil -} func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error { for _, place := range story.Places { place.Image = strings.TrimPrefix(place.Image, s.domain) + for _, application := range place.Applications { + application.Image = strings.TrimPrefix(application.Image, s.domain) + } } if story.Introduction != nil { story.Introduction.Audio = strings.TrimPrefix(story.Introduction.Audio, s.domain) @@ -459,8 +451,9 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString) } -// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды -// и возвращает JSON истории. Общая для редактирования и импорта. +// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды, +// нормализует file_type улик и возвращает JSON истории. +// Общая для редактирования и импорта. func normalizeStory(story *storytelling.Story) (string, error) { codes := map[string]struct{}{} for _, place := range story.Places { @@ -475,9 +468,28 @@ func normalizeStory(story *storytelling.Story) (string, error) { if place.Code == "" { continue } + for _, application := range place.Applications { + // file_type принимает только известные значения; невалидные + // (включая значения из импортированных архивов) заменяются + // деривацией из расширения файла. + application.FileType = normalizeFileType(application.FileType, application.Image) + } cleanPlaces = append(cleanPlaces, place) } story.Places = cleanPlaces return convertStory(story) } + +// normalizeFileType приводит file_type к одному из {pdf, image, audio, ""}. +// Пустое/невалидное значение при сохранении заменяется деривацией из +// расширения файла; пустое значение в legacy-строках деривируется при +// чтении (mapStory). +func normalizeFileType(fileType, image string) string { + switch fileType { + case "pdf", "image", "audio": + return fileType + default: + return file_storage.FileType(image) + } +} diff --git a/migrations/20260829120000_add_file_type_to_applications.sql b/migrations/20260829120000_add_file_type_to_applications.sql new file mode 100644 index 0000000..87d8572 --- /dev/null +++ b/migrations/20260829120000_add_file_type_to_applications.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE applications + ADD COLUMN IF NOT EXISTS file_type TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE applications + DROP COLUMN IF EXISTS file_type; diff --git a/proto/main.pb.go b/proto/main.pb.go index e648c47..0722c10 100644 --- a/proto/main.pb.go +++ b/proto/main.pb.go @@ -1640,9 +1640,12 @@ func (x *UploadFileReq) GetData() []byte { } type UploadFileRsp struct { - state protoimpl.MessageState `protogen:"open.v1"` - Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` - Filename string `protobuf:"bytes,2,opt,name=filename,proto3" json:"filename,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + Filename string `protobuf:"bytes,2,opt,name=filename,proto3" json:"filename,omitempty"` + // Тип загруженного файла: image | pdf | audio (определяется сервером по + // содержимому). Удобен редактору для предзаполнения file_type улики. + FileType string `protobuf:"bytes,3,opt,name=file_type,proto3" json:"file_type,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1691,6 +1694,13 @@ func (x *UploadFileRsp) GetFilename() string { return "" } +func (x *UploadFileRsp) GetFileType() string { + if x != nil { + return x.FileType + } + return "" +} + type DownloadFileReq struct { state protoimpl.MessageState `protogen:"open.v1"` Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"` @@ -2424,9 +2434,15 @@ func (x *Place) GetKeys() []*Key { } type Application struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Image string `protobuf:"bytes,2,opt,name=image,proto3" json:"image,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Image string `protobuf:"bytes,2,opt,name=image,proto3" json:"image,omitempty"` + // Тип файла улики: image | pdf | audio. Значение выводится сервером из + // расширения файла (см. file_storage.FileType); пустое значение возможно + // только для legacy-строк без расширения. Явный json_name — осознанное + // отклонение от camelCase-конвенции REST (единый snake_case с внутренним + // JSON истории). + FileType string `protobuf:"bytes,3,opt,name=file_type,proto3" json:"file_type,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2475,6 +2491,13 @@ func (x *Application) GetImage() string { return "" } +func (x *Application) GetFileType() string { + if x != nil { + return x.FileType + } + return "" +} + type Door struct { state protoimpl.MessageState `protogen:"open.v1"` Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` @@ -5374,10 +5397,11 @@ const file_main_proto_rawDesc = "" + "\vpermissions\x18\x02 \x03(\tR\vpermissions\"?\n" + "\rUploadFileReq\x12\x1a\n" + "\bfilename\x18\x01 \x01(\tR\bfilename\x12\x12\n" + - "\x04data\x18\x02 \x01(\fR\x04data\"A\n" + + "\x04data\x18\x02 \x01(\fR\x04data\"_\n" + "\rUploadFileRsp\x12\x14\n" + "\x05error\x18\x01 \x01(\tR\x05error\x12\x1a\n" + - "\bfilename\x18\x02 \x01(\tR\bfilename\"-\n" + + "\bfilename\x18\x02 \x01(\tR\bfilename\x12\x1c\n" + + "\tfile_type\x18\x03 \x01(\tR\tfile_type\"-\n" + "\x0fDownloadFileReq\x12\x1a\n" + "\bfilename\x18\x01 \x01(\tR\bfilename\"$\n" + "\x0eAddScenarioReq\x12\x12\n" + @@ -5428,10 +5452,11 @@ const file_main_proto_rawDesc = "" + "\x06hidden\x18\x05 \x01(\bR\x06hidden\x12O\n" + "\fapplications\x18\x06 \x03(\v2+.crabs.evening_detective_server.ApplicationR\fapplications\x12:\n" + "\x05doors\x18\a \x03(\v2$.crabs.evening_detective_server.DoorR\x05doors\x127\n" + - "\x04keys\x18\b \x03(\v2#.crabs.evening_detective_server.KeyR\x04keys\"7\n" + + "\x04keys\x18\b \x03(\v2#.crabs.evening_detective_server.KeyR\x04keys\"U\n" + "\vApplication\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + - "\x05image\x18\x02 \x01(\tR\x05image\"g\n" + + "\x05image\x18\x02 \x01(\tR\x05image\x12\x1c\n" + + "\tfile_type\x18\x03 \x01(\tR\tfile_type\"g\n" + "\x04Door\x12\x12\n" + "\x04code\x18\x01 \x01(\tR\x04code\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x127\n" +