This commit is contained in:
2026-08-20 23:17:52 +07:00
parent 6d37610348
commit 7656100fe6
19 changed files with 2808 additions and 455 deletions
+82
View File
@@ -2,6 +2,8 @@ package app
import (
"context"
"errors"
"fmt"
"net/netip"
"strings"
@@ -9,6 +11,9 @@ import (
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/roles"
"evening_detective_server/internal/modules/scenario_archive"
"evening_detective_server/internal/modules/string_tools"
"evening_detective_server/internal/repos/scenarios_repo"
"evening_detective_server/internal/services/file_service"
"evening_detective_server/internal/services/game_service"
"evening_detective_server/internal/services/scenarios_service"
@@ -17,6 +22,7 @@ import (
proto "evening_detective_server/proto"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
@@ -571,6 +577,82 @@ func (s *server) DeleteScenarioPlace(ctx context.Context, req *proto.DeleteScena
return &proto.DeleteScenarioPlaceRsp{}, nil
}
// DownloadScenarioArchive отдаёт ZIP-архив сценария; ошибки — gRPC-статусами.
func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
if !roles.HasRole(claims, roles.Author) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
data, name, err := s.scenarioService.DownloadArchive(
ctx,
int(req.Id),
claims.UserID,
roles.HasRole(claims, roles.Admin),
)
if err != nil {
switch {
case errors.Is(err, scenarios_repo.ErrScenarioNotFound):
return nil, status.Errorf(codes.NotFound, "%v", err)
case errors.Is(err, scenarios_service.ErrScenarioNotOwner):
return nil, status.Errorf(codes.PermissionDenied, "%v", err)
default:
return nil, status.Errorf(codes.Internal, "%v", err)
}
}
// Имя файла транслитерируется (заголовки HTTP — только ASCII) и
// санитайзится от header injection; заголовок пробрасывается gateway.
transliterated := sanitizeFilename(string_tools.Transliterate(name))
if transliterated == "" {
transliterated = "scenario"
}
filename := transliterated + ".zip"
if err := grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))); err != nil {
return nil, status.Errorf(codes.Internal, "failed to set response header: %v", err)
}
return &httpbody.HttpBody{
Data: data,
ContentType: "application/zip",
}, nil
}
// UploadScenarioArchive создаёт сценарий из ZIP-архива. Тело — сырые байты;
// JSON base64 тоже поддерживается.
func (s *server) UploadScenarioArchive(ctx context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
if !roles.HasRole(claims, roles.Author) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if req == nil || len(req.Data) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "пустое тело запроса")
}
if len(req.Data) > scenario_archive.MaxArchiveSize() {
return nil, status.Errorf(codes.InvalidArgument, "архив больше максимального размера (%d байт)", scenario_archive.MaxArchiveSize())
}
id, err := s.scenarioService.UploadArchive(ctx, req.Data, claims.UserID)
if err != nil {
return &proto.UploadScenarioArchiveRsp{
Error: err.Error(),
}, nil
}
return &proto.UploadScenarioArchiveRsp{
Id: int32(id),
}, nil
}
// sanitizeFilename — защита от инъекции заголовков в Content-Disposition.
func sanitizeFilename(name string) string {
replacer := strings.NewReplacer(
`"`, "_",
"\r", "_",
"\n", "_",
)
return replacer.Replace(name)
}
func (s *server) AddGame(ctx context.Context, req *proto.AddGameReq) (*proto.AddGameRsp, error) {
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {