This commit is contained in:
2026-10-11 02:23:45 +07:00
parent 62b623d3ca
commit 72ecdc0687
18 changed files with 2520 additions and 1 deletions
+107
View File
@@ -0,0 +1,107 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log"
"mime"
"net/http"
"strings"
"time"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/services/game_import_service"
"evening_detective_server/internal/services/game_package_service"
"evening_detective_server/proto"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// IGameImporter is shared by the streaming HTTP and bounded gRPC transports.
type IGameImporter interface {
Import(context.Context, io.Reader, int) (*game_import_service.Result, error)
}
func (s *server) ImportGame(ctx context.Context, req *httpbody.HttpBody) (*proto.GameImportRsp, error) {
claims, err := claimsFromContext(ctx)
if err != nil {
return nil, err
}
if !canExportGame(claims) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if !isZIP(req.GetContentType()) {
return nil, status.Error(codes.InvalidArgument, "Требуется application/zip")
}
result, err := s.gameImportService.Import(ctx, bytes.NewReader(req.GetData()), claims.UserID)
if err != nil {
return nil, gameImportErrorStatus(err)
}
return mapGameImport(result), nil
}
// GameImportHandler receives ZIPs directly, without the gRPC message buffer or multipart encoding.
func GameImportHandler(jwt processor_jwt.IProcessorJWT, importer IGameImporter) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
fail := func(err error) {
code := runtime.HTTPStatusFromCode(status.Code(err))
if status.Code(err) == codes.ResourceExhausted {
code = http.StatusRequestEntityTooLarge
}
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": status.Convert(err).Message()})
}
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || token == "" {
fail(status.Error(codes.Unauthenticated, "Требуется авторизация"))
return
}
claims, err := jwt.GetClaims(token)
if err != nil {
fail(status.Error(codes.Unauthenticated, "Недействительный токен"))
return
}
if !canExportGame(claims) {
fail(status.Error(codes.PermissionDenied, "permission denied"))
return
}
if !isZIP(r.Header.Get("Content-Type")) {
fail(status.Error(codes.InvalidArgument, "Требуется application/zip"))
return
}
if r.ContentLength > game_package_service.MaxArchiveSize {
fail(gameImportErrorStatus(game_package_service.ErrLimitExceeded))
return
}
if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(30 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
fail(gameImportErrorStatus(err))
return
}
r.Body = http.MaxBytesReader(w, r.Body, game_package_service.MaxArchiveSize)
result, err := importer.Import(r.Context(), r.Body, claims.UserID)
if err != nil {
fail(gameImportErrorStatus(err))
return
}
if err := json.NewEncoder(w).Encode(map[string]any{
"gameID": result.GameID,
"packageID": result.PackageID,
"repeated": result.Repeated,
}); err != nil {
log.Printf("game import response: %v", err)
}
})
}
func isZIP(contentType string) bool {
value, _, err := mime.ParseMediaType(contentType)
return err == nil && value == "application/zip"
}
+37
View File
@@ -0,0 +1,37 @@
package app
import (
"context"
"errors"
"log"
"net/http"
"os"
"evening_detective_server/internal/services/game_import_service"
"evening_detective_server/internal/services/game_package_service"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func gameImportErrorStatus(err error) error {
var sizeError *http.MaxBytesError
switch {
case errors.Is(err, game_import_service.ErrOnlineMode):
return status.Error(codes.FailedPrecondition, "Импорт игры доступен только на площадке")
case errors.Is(err, game_import_service.ErrConflict):
return status.Error(codes.Aborted, "Пакет конфликтует с локальными данными. Существующие записи не изменены.")
case errors.Is(err, game_import_service.ErrInProgress), errors.Is(err, game_import_service.ErrInterrupted):
return status.Error(codes.Aborted, "Импорт выполняется или был прерван. Повторите загрузку позже.")
case errors.Is(err, game_package_service.ErrLimitExceeded), errors.As(err, &sizeError):
return status.Error(codes.ResourceExhausted, "Пакет игры превышает допустимый размер")
case errors.Is(err, game_package_service.ErrInvalidPackage), errors.Is(err, game_package_service.ErrUnsupportedVersion):
return status.Error(codes.InvalidArgument, "Некорректный или неподдерживаемый пакет игры")
case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, "Импорт отменён")
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, os.ErrDeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "Время импорта истекло")
default:
log.Printf("game import: %v", err)
return status.Error(codes.Internal, "Не удалось импортировать игру")
}
}
+14
View File
@@ -0,0 +1,14 @@
package app
import (
"evening_detective_server/internal/services/game_import_service"
"evening_detective_server/proto"
)
func mapGameImport(result *game_import_service.Result) *proto.GameImportRsp {
return &proto.GameImportRsp{
GameID: int32(result.GameID),
PackageID: result.PackageID,
Repeated: result.Repeated,
}
}
+170
View File
@@ -0,0 +1,170 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/services/game_import_service"
"evening_detective_server/internal/services/game_package_service"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type importStub struct {
calls int
err error
actor int
}
func (s *importStub) Import(ctx context.Context, body io.Reader, actor int) (*game_import_service.Result, error) {
s.calls++
s.actor = actor
if s.err != nil {
return nil, s.err
}
raw, err := io.ReadAll(body)
if err != nil {
return nil, err
}
if string(raw) != "ZIP bytes" {
return nil, errors.New("wrong body")
}
return &game_import_service.Result{GameID: 100, PackageID: "package", Repeated: true}, nil
}
func TestImportHTTPAccessAndResponse(t *testing.T) {
for _, tc := range []struct {
name, role, auth, mime string
size int64
want int
called bool
}{
{"organizer", "organizer", "valid", "application/zip", 9, 200, true},
{"admin", "admin", "valid", "application/zip", 9, 200, true},
{"developer", "developer", "valid", "application/zip", 9, 200, true},
{"author", "author", "valid", "application/zip", 9, 403, false},
{"user", "user", "valid", "application/zip", 9, 403, false},
{"missing JWT", "", "", "application/zip", 9, 401, false},
{"bad JWT", "", "Bearer bad", "application/zip", 9, 401, false},
{"missing bearer prefix", "organizer", "no-prefix", "application/zip", 9, 401, false},
{"wrong MIME", "organizer", "valid", "application/json", 9, 400, false},
{"oversized", "organizer", "valid", "application/zip", game_package_service.MaxArchiveSize + 1, 413, false},
{"chunked upload", "organizer", "valid", "application/zip", -1, 200, true},
} {
t.Run(tc.name, func(t *testing.T) {
jwt := processor_jwt.NewProcessor("import-test-secret")
token, err := jwt.GenerateAccessToken(17, "organizer@test.invalid", []string{tc.role})
if err != nil {
t.Fatal(err)
}
stub := &importStub{}
req := httptest.NewRequest(http.MethodPost, "/api/games/import", strings.NewReader("ZIP bytes"))
req.ContentLength = tc.size
req.Header.Set("Content-Type", tc.mime)
switch tc.auth {
case "valid":
req.Header.Set("Authorization", "Bearer "+token)
case "no-prefix":
req.Header.Set("Authorization", token)
default:
req.Header.Set("Authorization", tc.auth)
}
w := httptest.NewRecorder()
GameImportHandler(jwt, stub).ServeHTTP(w, req)
if w.Code != tc.want || (stub.calls == 1) != tc.called {
t.Fatalf("status %d, calls %d", w.Code, stub.calls)
}
if w.Header().Get("Cache-Control") != "no-store" {
t.Fatal("response cacheable")
}
if tc.called {
var result struct {
GameID int `json:"gameID"`
PackageID string `json:"packageID"`
Repeated bool `json:"repeated"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if stub.actor != 17 || result.GameID != 100 || result.PackageID != "package" || !result.Repeated {
t.Fatal("incorrect response or actor")
}
}
})
}
}
func TestImportErrorMapping(t *testing.T) {
for _, tc := range []struct {
name string
err error
code codes.Code
http int
}{
{"online", game_import_service.ErrOnlineMode, codes.FailedPrecondition, 400},
{"conflict", game_import_service.ErrConflict, codes.Aborted, 409},
{"busy", game_import_service.ErrInProgress, codes.Aborted, 409},
{"interrupted", game_import_service.ErrInterrupted, codes.Aborted, 409},
{"invalid", game_package_service.ErrInvalidPackage, codes.InvalidArgument, 400},
{"version", game_package_service.ErrUnsupportedVersion, codes.InvalidArgument, 400},
{"limit", game_package_service.ErrLimitExceeded, codes.ResourceExhausted, 413},
{"body limit", &http.MaxBytesError{Limit: 1}, codes.ResourceExhausted, 413},
{"cancel", context.Canceled, codes.Canceled, 499},
{"deadline", context.DeadlineExceeded, codes.DeadlineExceeded, 504},
{"read deadline", os.ErrDeadlineExceeded, codes.DeadlineExceeded, 504},
{"unknown", errors.New("private SQL secret path"), codes.Internal, 500},
} {
t.Run(tc.name, func(t *testing.T) {
mapped := gameImportErrorStatus(fmt.Errorf("private detail: %w", tc.err))
if status.Code(mapped) != tc.code || strings.Contains(status.Convert(mapped).Message(), "private") {
t.Fatalf("unsafe or wrong mapping: %v", mapped)
}
jwt := processor_jwt.NewProcessor("import-test-secret")
token, err := jwt.GenerateAccessToken(1, "test@invalid", []string{"organizer"})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/games/import", strings.NewReader("ZIP bytes"))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/zip")
w := httptest.NewRecorder()
GameImportHandler(jwt, &importStub{err: tc.err}).ServeHTTP(w, req)
if w.Code != tc.http || strings.Contains(w.Body.String(), "private") {
t.Fatalf("unsafe or wrong HTTP response: %d %s", w.Code, w.Body.String())
}
})
}
}
func TestImportRPCRejectsUnauthorizedCallers(t *testing.T) {
for _, tc := range []struct {
name, role, mime string
want codes.Code
}{
{"guest", "", "application/zip", codes.Unauthenticated},
{"user", "user", "application/zip", codes.PermissionDenied},
{"author", "author", "application/zip", codes.PermissionDenied},
{"invalid content type", "organizer", "text/plain", codes.InvalidArgument},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
if tc.role != "" {
ctx = processor_jwt.ContextWithClaims(ctx, &processor_jwt.JWTClaims{UserID: 1, Roles: []string{tc.role}})
}
_, err := (&server{}).ImportGame(ctx, &httpbody.HttpBody{ContentType: tc.mime})
if status.Code(err) != tc.want {
t.Fatalf("got %v", err)
}
})
}
}
+4
View File
@@ -4,6 +4,7 @@ import (
"evening_detective_server/internal/services/answer_service"
"evening_detective_server/internal/services/file_service"
"evening_detective_server/internal/services/game_export_service"
"evening_detective_server/internal/services/game_import_service"
"evening_detective_server/internal/services/game_review_service"
"evening_detective_server/internal/services/game_service"
"evening_detective_server/internal/services/gameplay_service"
@@ -30,6 +31,7 @@ type server struct {
fileService *file_service.FileService
scenarioService *scenarios_service.ScenarioService
gameExportService *game_export_service.GameExportService
gameImportService *game_import_service.GameImportService
gameService *game_service.GameService
gameplayService *gameplay_service.GameplayService
answerService *answer_service.AnswerService
@@ -62,8 +64,10 @@ func NewServer(
mapService *map_service.MapService,
taskService *task_service.TaskService,
gameExportService *game_export_service.GameExportService,
gameImportService *game_import_service.GameImportService,
) proto.EveningDetectiveServerServer {
return &server{
gameImportService: gameImportService,
taskService: taskService,
gameExportService: gameExportService,
usersService: usersService,