generated from VLADIMIR/template
add load applications
This commit is contained in:
@@ -735,6 +735,9 @@ message UploadFileReq {
|
|||||||
message UploadFileRsp {
|
message UploadFileRsp {
|
||||||
string error = 1;
|
string error = 1;
|
||||||
string filename = 2;
|
string filename = 2;
|
||||||
|
// Тип загруженного файла: image | pdf | audio (определяется сервером по
|
||||||
|
// содержимому). Удобен редактору для предзаполнения file_type улики.
|
||||||
|
string file_type = 3 [json_name = "file_type"];
|
||||||
}
|
}
|
||||||
|
|
||||||
message DownloadFileReq {
|
message DownloadFileReq {
|
||||||
@@ -810,6 +813,10 @@ message Place {
|
|||||||
message Application {
|
message Application {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string image = 2;
|
string image = 2;
|
||||||
|
// Тип файла улики: image | pdf | audio. Значение выводится сервером из
|
||||||
|
// расширения файла (см. file_storage.FileType); пустое значение возможно
|
||||||
|
// только для legacy-строк без расширения.
|
||||||
|
string file_type = 3 [json_name = "file_type"];
|
||||||
}
|
}
|
||||||
|
|
||||||
message Door {
|
message Door {
|
||||||
|
|||||||
Binary file not shown.
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -17,26 +18,30 @@ import (
|
|||||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
// archiveStub — минимальный gRPC-сервер, реализующий только архивные RPC.
|
// testStub — минимальный gRPC-сервер, реализующий архивные и файловые RPC.
|
||||||
type archiveStub struct {
|
type testStub struct {
|
||||||
proto.UnimplementedEveningDetectiveServerServer
|
proto.UnimplementedEveningDetectiveServerServer
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
uploaded []byte
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.uploaded = append([]byte(nil), req.Data...)
|
s.uploaded = append([]byte(nil), req.Data...)
|
||||||
return &proto.UploadScenarioArchiveRsp{Id: 42}, nil
|
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"`))
|
_ = grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", `attachment; filename="test.zip"`))
|
||||||
return &httpbody.HttpBody{
|
return &httpbody.HttpBody{
|
||||||
Data: []byte("zip-data"),
|
Data: []byte("zip-data"),
|
||||||
@@ -44,27 +49,62 @@ func (s *archiveStub) DownloadScenarioArchive(ctx context.Context, _ *proto.Down
|
|||||||
}, nil
|
}, 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
return append([]byte(nil), s.uploaded...)
|
return append([]byte(nil), s.uploaded...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestGateway поднимает gRPC-сервер со stub и grpc-gateway с теми же
|
// 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 {
|
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()
|
t.Helper()
|
||||||
|
|
||||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("listen: %v", err)
|
t.Fatalf("listen: %v", err)
|
||||||
}
|
}
|
||||||
gs := grpc.NewServer()
|
gs := grpc.NewServer(grpc.MaxRecvMsgSize(grpcMsgLimit))
|
||||||
proto.RegisterEveningDetectiveServerServer(gs, stub)
|
proto.RegisterEveningDetectiveServerServer(gs, stub)
|
||||||
go func() { _ = gs.Serve(lis) }()
|
go func() { _ = gs.Serve(lis) }()
|
||||||
t.Cleanup(gs.Stop)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("dial: %v", err)
|
t.Fatalf("dial: %v", err)
|
||||||
}
|
}
|
||||||
@@ -72,11 +112,15 @@ func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *http
|
|||||||
|
|
||||||
mux := runtime.NewServeMux(
|
mux := runtime.NewServeMux(
|
||||||
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
|
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
|
||||||
if strings.EqualFold(key, "Content-Disposition") {
|
switch {
|
||||||
|
case strings.EqualFold(key, "Content-Disposition"):
|
||||||
return "Content-Disposition", true
|
return "Content-Disposition", true
|
||||||
|
case strings.EqualFold(key, "X-Content-Type-Options"):
|
||||||
|
return "X-Content-Type-Options", true
|
||||||
}
|
}
|
||||||
return runtime.DefaultHeaderMatcher(key)
|
return runtime.DefaultHeaderMatcher(key)
|
||||||
}),
|
}),
|
||||||
|
runtime.WithErrorHandler(customErrorHandler),
|
||||||
runtime.WithMarshalerOption("application/zip", &rawBodyMarshaler{
|
runtime.WithMarshalerOption("application/zip", &rawBodyMarshaler{
|
||||||
Marshaler: &runtime.HTTPBodyMarshaler{Marshaler: &runtime.JSONPb{}},
|
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 {
|
if err := proto.RegisterEveningDetectiveServerHandler(context.Background(), mux, conn); err != nil {
|
||||||
t.Fatalf("register gateway: %v", err)
|
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)
|
t.Cleanup(ts.Close)
|
||||||
return ts
|
return ts
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayUploadRawZip(t *testing.T) {
|
func TestGatewayUploadRawZip(t *testing.T) {
|
||||||
stub := &archiveStub{}
|
stub := &testStub{}
|
||||||
ts := newTestGateway(t, stub)
|
ts := newTestGateway(t, stub)
|
||||||
|
|
||||||
raw := []byte("PK\x03\x04raw-zip-bytes")
|
raw := []byte("PK\x03\x04raw-zip-bytes")
|
||||||
@@ -113,7 +161,7 @@ func TestGatewayUploadRawZip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
|
func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
|
||||||
stub := &archiveStub{}
|
stub := &testStub{}
|
||||||
ts := newTestGateway(t, stub)
|
ts := newTestGateway(t, stub)
|
||||||
|
|
||||||
resp, err := http.Get(ts.URL + "/api/scenarios/1/archive")
|
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
|
// TestCORSHeaders — фронт через fetch должен читать Content-Disposition
|
||||||
// (имя файла архива) из ответа, а браузерный MCP-клиент — заголовки
|
// (имя файла архива) из ответа, а браузерный MCP-клиент — заголовки
|
||||||
// streamable HTTP /mcp (Mcp-Session-Id, Mcp-Protocol-Version) в allow/expose.
|
// streamable HTTP /mcp (Mcp-Session-Id, Mcp-Protocol-Version) в allow/expose.
|
||||||
@@ -156,35 +434,3 @@ func TestCORSHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLimitArchiveUploadBody — тело загрузки архива ограничено на HTTP-слое
|
|
||||||
// (защита от OOM), прочие маршруты не затронуты.
|
|
||||||
func TestLimitArchiveUploadBody(t *testing.T) {
|
|
||||||
h := limitArchiveUploadBody(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
t.Run("upload route rejected when over limit", func(t *testing.T) {
|
|
||||||
big := bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/scenarios/archive", bytes.NewReader(big))
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("other routes not limited", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/files/upload", bytes.NewReader([]byte("small")))
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"errors"
|
||||||
"evening_detective_server/internal/app"
|
"evening_detective_server/internal/app"
|
||||||
"evening_detective_server/internal/modules/cleaner"
|
"evening_detective_server/internal/modules/cleaner"
|
||||||
"evening_detective_server/internal/modules/email_sender"
|
"evening_detective_server/internal/modules/email_sender"
|
||||||
@@ -26,6 +27,7 @@ import (
|
|||||||
"evening_detective_server/internal/services/ui_service"
|
"evening_detective_server/internal/services/ui_service"
|
||||||
"evening_detective_server/internal/services/users_service"
|
"evening_detective_server/internal/services/users_service"
|
||||||
proto "evening_detective_server/proto"
|
proto "evening_detective_server/proto"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -38,7 +40,9 @@ import (
|
|||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
|
||||||
"github.com/swaggest/swgui/v5emb"
|
"github.com/swaggest/swgui/v5emb"
|
||||||
@@ -50,6 +54,16 @@ var swaggerJSON []byte
|
|||||||
// maxFileSize — максимальный размер файла, передаваемого через gRPC
|
// maxFileSize — максимальный размер файла, передаваемого через gRPC
|
||||||
const maxFileSize = 64 << 20 // 64 МБ
|
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() {
|
func main() {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
@@ -96,7 +110,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to create connection rustfs: %v\n", err)
|
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)
|
scenariosRepo := scenarios_repo.NewScenariosRepo(dbpool)
|
||||||
cleaner := cleaner.NewCleaner()
|
cleaner := cleaner.NewCleaner()
|
||||||
scenarioService := scenarios_service.NewScenarioService(
|
scenarioService := scenarios_service.NewScenarioService(
|
||||||
@@ -132,7 +146,7 @@ func main() {
|
|||||||
|
|
||||||
// Create a gRPC server object
|
// Create a gRPC server object
|
||||||
s := grpc.NewServer(
|
s := grpc.NewServer(
|
||||||
grpc.MaxRecvMsgSize(maxFileSize),
|
grpc.MaxRecvMsgSize(grpcMsgLimit),
|
||||||
grpc.UnaryInterceptor(
|
grpc.UnaryInterceptor(
|
||||||
processor_jwt.NewAuthorizationInterceptor(
|
processor_jwt.NewAuthorizationInterceptor(
|
||||||
map[string]bool{
|
map[string]bool{
|
||||||
@@ -179,8 +193,8 @@ func main() {
|
|||||||
"0.0.0.0:8080",
|
"0.0.0.0:8080",
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
grpc.WithDefaultCallOptions(
|
grpc.WithDefaultCallOptions(
|
||||||
grpc.MaxCallSendMsgSize(maxFileSize),
|
grpc.MaxCallSendMsgSize(grpcMsgLimit),
|
||||||
grpc.MaxCallRecvMsgSize(maxFileSize),
|
grpc.MaxCallRecvMsgSize(grpcMsgLimit),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -212,13 +226,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
return runtime.DefaultHeaderMatcher(key)
|
return runtime.DefaultHeaderMatcher(key)
|
||||||
}),
|
}),
|
||||||
// Проброс Content-Disposition от сервиса в HTTP-ответ.
|
// Проброс Content-Disposition и X-Content-Type-Options (nosniff)
|
||||||
|
// от сервиса в HTTP-ответ.
|
||||||
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
|
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
|
||||||
if strings.EqualFold(key, "Content-Disposition") {
|
switch {
|
||||||
|
case strings.EqualFold(key, "Content-Disposition"):
|
||||||
return "Content-Disposition", true
|
return "Content-Disposition", true
|
||||||
|
case strings.EqualFold(key, "X-Content-Type-Options"):
|
||||||
|
return "X-Content-Type-Options", true
|
||||||
}
|
}
|
||||||
return runtime.DefaultHeaderMatcher(key)
|
return runtime.DefaultHeaderMatcher(key)
|
||||||
}),
|
}),
|
||||||
|
runtime.WithErrorHandler(customErrorHandler),
|
||||||
runtime.WithMarshalerOption("application/zip", rawBody),
|
runtime.WithMarshalerOption("application/zip", rawBody),
|
||||||
runtime.WithMarshalerOption("application/octet-stream", rawBody),
|
runtime.WithMarshalerOption("application/octet-stream", rawBody),
|
||||||
runtime.WithMarshalerOption("application/x-zip-compressed", rawBody),
|
runtime.WithMarshalerOption("application/x-zip-compressed", rawBody),
|
||||||
@@ -238,8 +257,17 @@ func main() {
|
|||||||
w.Write(swaggerJSON)
|
w.Write(swaggerJSON)
|
||||||
})
|
})
|
||||||
// MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита
|
// MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита
|
||||||
// проверки в хендлере не спасут от OOM.
|
// проверки в хендлере не спасут от OOM. Лимиты по маршрутам: файлы —
|
||||||
mainMux.Handle("/api/", limitArchiveUploadBody(gwmux))
|
// 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-сервер (Model Context Protocol): эндпоинт /api/mcp (streamable HTTP).
|
||||||
// Инструменты MCP вызывают игровые сервисы напрямую (см. mcp_service).
|
// Инструменты MCP вызывают игровые сервисы напрямую (см. mcp_service).
|
||||||
@@ -284,13 +312,62 @@ func cors(h http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitArchiveUploadBody ограничивает тело запроса загрузки архива
|
// limitUploadBody ограничивает тело POST-запроса на заданном маршруте
|
||||||
// (см. rawBodyDecoder: gateway читает тело в память целиком).
|
// (gateway читает тело в память целиком).
|
||||||
func limitArchiveUploadBody(next http.Handler) http.Handler {
|
func limitUploadBody(route string, limit int64, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodPost && r.URL.Path == "/api/scenarios/archive" {
|
if r.Method == http.MethodPost && r.URL.Path == route {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, int64(scenario_archive.MaxArchiveSize()))
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// customErrorHandler мапит ошибки превышения размера тела в HTTP 413
|
||||||
|
// с понятным текстом:
|
||||||
|
// - *http.MaxBytesError (raw) — только для юнит-тестов: сгенерированный
|
||||||
|
// gateway-код строкифицирует ошибку декодера через %v и тип теряется;
|
||||||
|
// - gRPC ResourceExhausted на путях загрузки (файл больше лимита);
|
||||||
|
// - InvalidArgument с текстом MaxBytesError ("http: request body too
|
||||||
|
// large") — единственная реальная защита 413 на HTTP-слое, ветку нельзя
|
||||||
|
// удалять при «упрощении» обработчика.
|
||||||
|
//
|
||||||
|
// Остальные ошибки обрабатываются как обычно (DefaultHTTPErrorHandler).
|
||||||
|
func customErrorHandler(
|
||||||
|
ctx context.Context,
|
||||||
|
mux *runtime.ServeMux,
|
||||||
|
marshaler runtime.Marshaler,
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
var maxBytesErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxBytesErr) {
|
||||||
|
writeTooLargeError(w, marshaler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status.Code(err) == codes.ResourceExhausted {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/files/upload", "/api/scenarios/archive":
|
||||||
|
writeTooLargeError(w, marshaler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status.Code(err) == codes.InvalidArgument &&
|
||||||
|
strings.Contains(status.Convert(err).Message(), "request body too large") {
|
||||||
|
writeTooLargeError(w, marshaler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, w, r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTooLargeError — ответ 413 в том же формате {"error": ...}, что и
|
||||||
|
// DefaultHTTPErrorHandler. Текст лимита формируется из maxFileSize, чтобы
|
||||||
|
// не расходиться с реальным лимитом при его изменении.
|
||||||
|
func writeTooLargeError(w http.ResponseWriter, marshaler runtime.Marshaler) {
|
||||||
|
w.Header().Set("Content-Type", marshaler.ContentType(nil))
|
||||||
|
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||||
|
_ = marshaler.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"error": fmt.Sprintf("файл слишком большой (лимит %d МБ)", maxFileSize>>20),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1962,6 +1962,10 @@
|
|||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"type": "string"
|
"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": {
|
"filename": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"file_type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Тип загруженного файла: image | pdf | audio (определяется сервером по\nсодержимому). Удобен редактору для предзаполнения file_type улики."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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/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/ssooidc v1.36.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // 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/google/jsonschema-go v0.4.2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
@@ -41,6 +40,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/smithy-go v1.27.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-11
@@ -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) {
|
func (s *server) UploadFile(ctx context.Context, req *proto.UploadFileReq) (*proto.UploadFileRsp, error) {
|
||||||
filename, err := s.fileService.UploadFile(
|
// Файлы (в т.ч. улики) загружают авторы сценариев в редакторе.
|
||||||
ctx,
|
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||||
&file_storage.File{
|
if !roles.HasRole(claims, roles.Author) {
|
||||||
Name: req.Filename,
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||||
Data: req.Data,
|
}
|
||||||
},
|
|
||||||
)
|
filename, fileType, err := s.fileService.UploadFile(ctx, req.Filename, req.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &proto.UploadFileRsp{
|
switch {
|
||||||
Error: err.Error(),
|
case errors.Is(err, file_service.ErrFileTooLarge):
|
||||||
}, nil
|
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{
|
return &proto.UploadFileRsp{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
|
FileType: fileType,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) DownloadFile(ctx context.Context, req *proto.DownloadFileReq) (*httpbody.HttpBody, error) {
|
func (s *server) DownloadFile(ctx context.Context, req *proto.DownloadFileReq) (*httpbody.HttpBody, error) {
|
||||||
file, err := s.fileService.DownloadFile(ctx, req.Filename)
|
file, err := s.fileService.DownloadFile(ctx, req.Filename)
|
||||||
if err != nil {
|
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{
|
return &httpbody.HttpBody{
|
||||||
Data: file.Data,
|
Data: file.Data,
|
||||||
|
|||||||
@@ -109,8 +109,9 @@ func mapKeys(o []*storytelling.Key) []*proto.Key {
|
|||||||
|
|
||||||
func mapApplication(o *storytelling.Application) *proto.Application {
|
func mapApplication(o *storytelling.Application) *proto.Application {
|
||||||
return &proto.Application{
|
return &proto.Application{
|
||||||
Name: o.Name,
|
Name: o.Name,
|
||||||
Image: o.Image,
|
Image: o.Image,
|
||||||
|
FileType: o.FileType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,8 +168,9 @@ func convertKeys(o []*proto.Key) []*storytelling.Key {
|
|||||||
|
|
||||||
func convertApplication(o *proto.Application) *storytelling.Application {
|
func convertApplication(o *proto.Application) *storytelling.Application {
|
||||||
return &storytelling.Application{
|
return &storytelling.Application{
|
||||||
Name: textFormatter.FormatString(o.Name),
|
Name: textFormatter.FormatString(o.Name),
|
||||||
Image: o.Image,
|
Image: o.Image,
|
||||||
|
FileType: o.FileType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
package file_storage
|
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 {
|
type File struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -14,3 +24,32 @@ type IFileStorage interface {
|
|||||||
Delete(ctx context.Context, filename string) error
|
Delete(ctx context.Context, filename string) error
|
||||||
MimeType(filename string) string
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ package file_storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"mime"
|
"mime"
|
||||||
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/smithy-go"
|
||||||
"github.com/kzzan/s3kit"
|
"github.com/kzzan/s3kit"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,19 +51,27 @@ func NewRustFSStorage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *storage) Put(ctx context.Context, file *File) error {
|
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, mimeFor(file))
|
||||||
return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *storage) Get(ctx context.Context, filename string) (*File, error) {
|
func (s *storage) Get(ctx context.Context, filename string) (*File, error) {
|
||||||
data, err := s.client.GetObjectBytes(ctx, s.bucket, filename)
|
data, err := s.client.GetObjectBytes(ctx, s.bucket, filename)
|
||||||
if err != nil {
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &File{
|
return &File{
|
||||||
Name: filename,
|
Name: filename,
|
||||||
Data: data,
|
Data: data,
|
||||||
Mime: s.MimeType(filename),
|
Mime: mimeFromData(data, filename),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,3 +85,36 @@ func (s *storage) MimeType(filename string) string {
|
|||||||
}
|
}
|
||||||
return "application/octet-stream"
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("<html>hi</html>"), "photo.png", "image/png"},
|
||||||
|
{"html unknown extension", []byte("<html>hi</html>"), "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,9 @@ type Application struct {
|
|||||||
|
|
||||||
// Картинка
|
// Картинка
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
|
|
||||||
|
// Тип файла улики: image | pdf | audio.
|
||||||
|
FileType string `json:"file_type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Дверь - действие или диалог
|
// Дверь - действие или диалог
|
||||||
|
|||||||
@@ -144,8 +144,9 @@ func (s *story) mapPlace(
|
|||||||
applications = append(
|
applications = append(
|
||||||
applications,
|
applications,
|
||||||
&Application{
|
&Application{
|
||||||
Name: s.cleaner.ClearText(application.Name),
|
Name: s.cleaner.ClearText(application.Name),
|
||||||
Image: application.Image,
|
Image: application.Image,
|
||||||
|
FileType: application.FileType,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,8 +229,9 @@ func Test_story_GetStory(t *testing.T) {
|
|||||||
Text: "Текст точки.",
|
Text: "Текст точки.",
|
||||||
Applications: []*Application{
|
Applications: []*Application{
|
||||||
{
|
{
|
||||||
Name: "Название улики",
|
Name: "Название улики",
|
||||||
Image: "image.png",
|
Image: "image.png",
|
||||||
|
FileType: "image",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -245,8 +246,9 @@ func Test_story_GetStory(t *testing.T) {
|
|||||||
Text: "Текст точки.",
|
Text: "Текст точки.",
|
||||||
Applications: []*Application{
|
Applications: []*Application{
|
||||||
{
|
{
|
||||||
Name: "Название улики",
|
Name: "Название улики",
|
||||||
Image: "image.png",
|
Image: "image.png",
|
||||||
|
FileType: "image",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package repos
|
package repos
|
||||||
|
|
||||||
type Application struct {
|
type Application struct {
|
||||||
Name string
|
Name string
|
||||||
Image string
|
Image string
|
||||||
TeamId int
|
TeamId int
|
||||||
|
FileType string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,15 +27,17 @@ func (r *ApplicationsRepo) AddApplication(
|
|||||||
teamId int,
|
teamId int,
|
||||||
name string,
|
name string,
|
||||||
image string,
|
image string,
|
||||||
|
fileType string,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
id := 0
|
id := 0
|
||||||
err := r.pool.QueryRow(
|
err := r.pool.QueryRow(
|
||||||
ctx,
|
ctx,
|
||||||
`INSERT INTO applications (name, image, team_id)
|
`INSERT INTO applications (name, image, file_type, team_id)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
name,
|
name,
|
||||||
image,
|
image,
|
||||||
|
fileType,
|
||||||
teamId,
|
teamId,
|
||||||
).Scan(&id)
|
).Scan(&id)
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState(
|
|||||||
`SELECT
|
`SELECT
|
||||||
name,
|
name,
|
||||||
image,
|
image,
|
||||||
|
file_type,
|
||||||
team_id
|
team_id
|
||||||
FROM applications
|
FROM applications
|
||||||
WHERE team_id = ANY($1) and status = $2`,
|
WHERE team_id = ANY($1) and status = $2`,
|
||||||
@@ -72,6 +75,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState(
|
|||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&application.Name,
|
&application.Name,
|
||||||
&application.Image,
|
&application.Image,
|
||||||
|
&application.FileType,
|
||||||
&application.TeamId,
|
&application.TeamId,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,34 +2,137 @@ package file_service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"evening_detective_server/internal/modules/file_storage"
|
"evening_detective_server/internal/modules/file_storage"
|
||||||
"evening_detective_server/internal/modules/string_tools"
|
"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 {
|
type FileService struct {
|
||||||
fileStorage file_storage.IFileStorage
|
fileStorage file_storage.IFileStorage
|
||||||
stringTools string_tools.IStringTools
|
stringTools string_tools.IStringTools
|
||||||
|
maxFileSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileService(
|
func NewFileService(
|
||||||
fileStorage file_storage.IFileStorage,
|
fileStorage file_storage.IFileStorage,
|
||||||
stringTools string_tools.IStringTools,
|
stringTools string_tools.IStringTools,
|
||||||
|
maxFileSize int,
|
||||||
) *FileService {
|
) *FileService {
|
||||||
return &FileService{
|
return &FileService{
|
||||||
fileStorage: fileStorage,
|
fileStorage: fileStorage,
|
||||||
stringTools: stringTools,
|
stringTools: stringTools,
|
||||||
|
maxFileSize: maxFileSize,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadFile сохраняет файл в хранилище под уникальным именем и возвращает
|
||||||
|
// сохранённое имя и категорию файла (image|pdf|audio).
|
||||||
|
//
|
||||||
|
// Порядок проверок: имя/данные → расширение → base → размер → содержимое.
|
||||||
|
// Проверка размера — defense-in-depth: фактический лимит запроса
|
||||||
|
// устанавливается на HTTP-слое (MaxBytesReader) и gRPC-лимитами.
|
||||||
func (s *FileService) UploadFile(
|
func (s *FileService) UploadFile(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
file *file_storage.File,
|
filename string,
|
||||||
) (string, error) {
|
data []byte,
|
||||||
file.Name = s.stringTools.Transliterate(file.Name)
|
) (string, string, error) {
|
||||||
if err := s.fileStorage.Put(ctx, file); err != nil {
|
if filename == "" {
|
||||||
return "", err
|
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(
|
func (s *FileService) DownloadFile(
|
||||||
|
|||||||
@@ -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("<html>hi</html>"), 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')
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package game_service
|
|
||||||
|
|
||||||
type Application struct {
|
|
||||||
Name string
|
|
||||||
Image string
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,64 @@
|
|||||||
package game_service
|
package game_service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evening_detective_server/internal/modules/file_storage"
|
||||||
"evening_detective_server/internal/modules/storytelling"
|
"evening_detective_server/internal/modules/storytelling"
|
||||||
"evening_detective_server/internal/repos"
|
"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))
|
res := make([]*storytelling.Application, 0, len(o))
|
||||||
for _, item := range o {
|
for _, item := range o {
|
||||||
res = append(res, mapApplication(item))
|
res = append(res, mapApplication(item, domain))
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapApplication(o *repos.Application) *storytelling.Application {
|
func mapApplication(o *repos.Application, domain string) *storytelling.Application {
|
||||||
return &storytelling.Application{
|
image := prefixDomain(o.Image, domain)
|
||||||
|
app := &storytelling.Application{
|
||||||
Name: o.Name,
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,7 +114,7 @@ func (s *GameService) GetFullGame(
|
|||||||
|
|
||||||
for _, team := range res.Teams {
|
for _, team := range res.Teams {
|
||||||
team.ActionsCount = actionsCountByTeamIDs[team.ID]
|
team.ActionsCount = actionsCountByTeamIDs[team.ID]
|
||||||
team.Applications = mapApplications(needApplicationsByTeamIDs[team.ID])
|
team.Applications = mapApplications(needApplicationsByTeamIDs[team.ID], s.domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
return res, nil
|
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]
|
lastPlace := teamStory.Places[len(teamStory.Places)-1]
|
||||||
|
|
||||||
for _, application := range lastPlace.Applications {
|
for _, application := range lastPlace.Applications {
|
||||||
_, err := s.applicationsRepo.AddApplication(ctx, teamId, s.cleaner.ClearText(application.Name), application.Image)
|
app := newApplication(application, s.domain)
|
||||||
if err != nil {
|
app.Name = s.cleaner.ClearText(app.Name)
|
||||||
|
if _, err := s.applicationsRepo.AddApplication(ctx, teamId, app.Name, app.Image, app.FileType); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package scenarios_service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evening_detective_server/internal/modules/file_storage"
|
||||||
"evening_detective_server/internal/modules/storytelling"
|
"evening_detective_server/internal/modules/storytelling"
|
||||||
"evening_detective_server/internal/repos"
|
"evening_detective_server/internal/repos"
|
||||||
)
|
)
|
||||||
@@ -73,16 +76,34 @@ func mapStory(o string, domain string) (*storytelling.Story, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, place := range res.Places {
|
for _, place := range res.Places {
|
||||||
if place.Image != "" {
|
place.Image = prefixDomain(place.Image, domain)
|
||||||
place.Image = domain + place.Image
|
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 != "" {
|
if res.Introduction != nil {
|
||||||
res.Introduction.Audio = domain + res.Introduction.Audio
|
res.Introduction.Audio = prefixDomain(res.Introduction.Audio, domain)
|
||||||
}
|
}
|
||||||
return res, nil
|
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) {
|
func convertStory(o *storytelling.Story) (string, error) {
|
||||||
b, err := json.Marshal(o)
|
b, err := json.Marshal(o)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@ package scenarios_service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"evening_detective_server/internal/modules/cleaner"
|
"evening_detective_server/internal/modules/cleaner"
|
||||||
"evening_detective_server/internal/modules/file_storage"
|
"evening_detective_server/internal/modules/file_storage"
|
||||||
@@ -373,10 +371,11 @@ func (s *ScenarioService) UploadArchive(
|
|||||||
if !exists {
|
if !exists {
|
||||||
return "", fmt.Errorf("изображение %q не найдено в архиве", archiveRef)
|
return "", fmt.Errorf("изображение %q не найдено в архиве", archiveRef)
|
||||||
}
|
}
|
||||||
name, err := newStorageName(archiveRef)
|
name, err := file_storage.NewName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
name += strings.ToLower(filepath.Ext(archiveRef))
|
||||||
if err := s.fileStorage.Put(ctx, &file_storage.File{
|
if err := s.fileStorage.Put(ctx, &file_storage.File{
|
||||||
Name: name,
|
Name: name,
|
||||||
Data: content,
|
Data: content,
|
||||||
@@ -433,20 +432,13 @@ func (s *ScenarioService) UploadArchive(
|
|||||||
return id, nil
|
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 {
|
func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error {
|
||||||
for _, place := range story.Places {
|
for _, place := range story.Places {
|
||||||
place.Image = strings.TrimPrefix(place.Image, s.domain)
|
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 {
|
if story.Introduction != nil {
|
||||||
story.Introduction.Audio = strings.TrimPrefix(story.Introduction.Audio, s.domain)
|
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)
|
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды
|
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды,
|
||||||
// и возвращает JSON истории. Общая для редактирования и импорта.
|
// нормализует file_type улик и возвращает JSON истории.
|
||||||
|
// Общая для редактирования и импорта.
|
||||||
func normalizeStory(story *storytelling.Story) (string, error) {
|
func normalizeStory(story *storytelling.Story) (string, error) {
|
||||||
codes := map[string]struct{}{}
|
codes := map[string]struct{}{}
|
||||||
for _, place := range story.Places {
|
for _, place := range story.Places {
|
||||||
@@ -475,9 +468,28 @@ func normalizeStory(story *storytelling.Story) (string, error) {
|
|||||||
if place.Code == "" {
|
if place.Code == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
for _, application := range place.Applications {
|
||||||
|
// file_type принимает только известные значения; невалидные
|
||||||
|
// (включая значения из импортированных архивов) заменяются
|
||||||
|
// деривацией из расширения файла.
|
||||||
|
application.FileType = normalizeFileType(application.FileType, application.Image)
|
||||||
|
}
|
||||||
cleanPlaces = append(cleanPlaces, place)
|
cleanPlaces = append(cleanPlaces, place)
|
||||||
}
|
}
|
||||||
story.Places = cleanPlaces
|
story.Places = cleanPlaces
|
||||||
|
|
||||||
return convertStory(story)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
+35
-10
@@ -1640,9 +1640,12 @@ func (x *UploadFileReq) GetData() []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UploadFileRsp struct {
|
type UploadFileRsp struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||||
Filename string `protobuf:"bytes,2,opt,name=filename,proto3" json:"filename,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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -1691,6 +1694,13 @@ func (x *UploadFileRsp) GetFilename() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *UploadFileRsp) GetFileType() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.FileType
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type DownloadFileReq struct {
|
type DownloadFileReq struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"`
|
Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"`
|
||||||
@@ -2424,9 +2434,15 @@ func (x *Place) GetKeys() []*Key {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Application struct {
|
type Application struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
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 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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -2475,6 +2491,13 @@ func (x *Application) GetImage() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *Application) GetFileType() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.FileType
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type Door struct {
|
type Door struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"`
|
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" +
|
"\vpermissions\x18\x02 \x03(\tR\vpermissions\"?\n" +
|
||||||
"\rUploadFileReq\x12\x1a\n" +
|
"\rUploadFileReq\x12\x1a\n" +
|
||||||
"\bfilename\x18\x01 \x01(\tR\bfilename\x12\x12\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" +
|
"\rUploadFileRsp\x12\x14\n" +
|
||||||
"\x05error\x18\x01 \x01(\tR\x05error\x12\x1a\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" +
|
"\x0fDownloadFileReq\x12\x1a\n" +
|
||||||
"\bfilename\x18\x01 \x01(\tR\bfilename\"$\n" +
|
"\bfilename\x18\x01 \x01(\tR\bfilename\"$\n" +
|
||||||
"\x0eAddScenarioReq\x12\x12\n" +
|
"\x0eAddScenarioReq\x12\x12\n" +
|
||||||
@@ -5428,10 +5452,11 @@ const file_main_proto_rawDesc = "" +
|
|||||||
"\x06hidden\x18\x05 \x01(\bR\x06hidden\x12O\n" +
|
"\x06hidden\x18\x05 \x01(\bR\x06hidden\x12O\n" +
|
||||||
"\fapplications\x18\x06 \x03(\v2+.crabs.evening_detective_server.ApplicationR\fapplications\x12:\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" +
|
"\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" +
|
"\vApplication\x12\x12\n" +
|
||||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\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" +
|
"\x04Door\x12\x12\n" +
|
||||||
"\x04code\x18\x01 \x01(\tR\x04code\x12\x12\n" +
|
"\x04code\x18\x01 \x01(\tR\x04code\x12\x12\n" +
|
||||||
"\x04name\x18\x02 \x01(\tR\x04name\x127\n" +
|
"\x04name\x18\x02 \x01(\tR\x04name\x127\n" +
|
||||||
|
|||||||
Reference in New Issue
Block a user