diff --git a/openapi-internal.yaml b/openapi-internal.yaml index 1cde29e..29a4d12 100644 --- a/openapi-internal.yaml +++ b/openapi-internal.yaml @@ -712,6 +712,8 @@ components: - GameID - AssetID - AssetVersion + - Status + - Roles type: object properties: OperationID: @@ -740,6 +742,14 @@ components: type: integer format: int64 minimum: 0 + Status: + type: integer + format: uint32 + minimum: 0 + maximum: 9 + Roles: + type: integer + format: uint32 Script: required: - ID diff --git a/openapi.yaml b/openapi.yaml index 2e94f30..2879ae8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -654,6 +654,31 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /submissions-admin: + post: + summary: Trigger the validator to create a new submission + operationId: createSubmissionAdmin + tags: + - Submissions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SubmissionTriggerCreate' + responses: + "201": + description: Successful response + content: + application/json: + schema: + $ref: "#/components/schemas/OperationID" + default: + description: General Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /submissions/{SubmissionID}: get: summary: Retrieve map with ID diff --git a/pkg/api/oas_client_gen.go b/pkg/api/oas_client_gen.go index 89085f9..7951723 100644 --- a/pkg/api/oas_client_gen.go +++ b/pkg/api/oas_client_gen.go @@ -181,6 +181,12 @@ type Invoker interface { // // POST /submissions CreateSubmission(ctx context.Context, request *SubmissionTriggerCreate) (*OperationID, error) + // CreateSubmissionAdmin invokes createSubmissionAdmin operation. + // + // Trigger the validator to create a new submission. + // + // POST /submissions-admin + CreateSubmissionAdmin(ctx context.Context, request *SubmissionTriggerCreate) (*OperationID, error) // CreateSubmissionAuditComment invokes createSubmissionAuditComment operation. // // Post a comment to the audit log. @@ -3429,6 +3435,114 @@ func (c *Client) sendCreateSubmission(ctx context.Context, request *SubmissionTr return result, nil } +// CreateSubmissionAdmin invokes createSubmissionAdmin operation. +// +// Trigger the validator to create a new submission. +// +// POST /submissions-admin +func (c *Client) CreateSubmissionAdmin(ctx context.Context, request *SubmissionTriggerCreate) (*OperationID, error) { + res, err := c.sendCreateSubmissionAdmin(ctx, request) + return res, err +} + +func (c *Client) sendCreateSubmissionAdmin(ctx context.Context, request *SubmissionTriggerCreate) (res *OperationID, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("createSubmissionAdmin"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/submissions-admin"), + } + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, CreateSubmissionAdminOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/submissions-admin" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + if err := encodeCreateSubmissionAdminRequest(request, r); err != nil { + return res, errors.Wrap(err, "encode request") + } + + { + type bitset = [1]uint8 + var satisfied bitset + { + stage = "Security:CookieAuth" + switch err := c.securityCookieAuth(ctx, CreateSubmissionAdminOperation, r); { + case err == nil: // if NO error + satisfied[0] |= 1 << 0 + case errors.Is(err, ogenerrors.ErrSkipClientSecurity): + // Skip this security. + default: + return res, errors.Wrap(err, "security \"CookieAuth\"") + } + } + + if ok := func() bool { + nextRequirement: + for _, requirement := range []bitset{ + {0b00000001}, + } { + for i, mask := range requirement { + if satisfied[i]&mask != mask { + continue nextRequirement + } + } + return true + } + return false + }(); !ok { + return res, ogenerrors.ErrSecurityRequirementIsNotSatisfied + } + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + defer resp.Body.Close() + + stage = "DecodeResponse" + result, err := decodeCreateSubmissionAdminResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + // CreateSubmissionAuditComment invokes createSubmissionAuditComment operation. // // Post a comment to the audit log. diff --git a/pkg/api/oas_handlers_gen.go b/pkg/api/oas_handlers_gen.go index e6bcc7b..13081b7 100644 --- a/pkg/api/oas_handlers_gen.go +++ b/pkg/api/oas_handlers_gen.go @@ -4922,6 +4922,201 @@ func (s *Server) handleCreateSubmissionRequest(args [0]string, argsEscaped bool, } } +// handleCreateSubmissionAdminRequest handles createSubmissionAdmin operation. +// +// Trigger the validator to create a new submission. +// +// POST /submissions-admin +func (s *Server) handleCreateSubmissionAdminRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("createSubmissionAdmin"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/submissions-admin"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), CreateSubmissionAdminOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code >= 100 && code < 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: CreateSubmissionAdminOperation, + ID: "createSubmissionAdmin", + } + ) + { + type bitset = [1]uint8 + var satisfied bitset + { + sctx, ok, err := s.securityCookieAuth(ctx, CreateSubmissionAdminOperation, r) + if err != nil { + err = &ogenerrors.SecurityError{ + OperationContext: opErrContext, + Security: "CookieAuth", + Err: err, + } + if encodeErr := encodeErrorResponse(s.h.NewError(ctx, err), w, span); encodeErr != nil { + defer recordError("Security:CookieAuth", err) + } + return + } + if ok { + satisfied[0] |= 1 << 0 + ctx = sctx + } + } + + if ok := func() bool { + nextRequirement: + for _, requirement := range []bitset{ + {0b00000001}, + } { + for i, mask := range requirement { + if satisfied[i]&mask != mask { + continue nextRequirement + } + } + return true + } + return false + }(); !ok { + err = &ogenerrors.SecurityError{ + OperationContext: opErrContext, + Err: ogenerrors.ErrSecurityRequirementIsNotSatisfied, + } + if encodeErr := encodeErrorResponse(s.h.NewError(ctx, err), w, span); encodeErr != nil { + defer recordError("Security", err) + } + return + } + } + request, close, err := s.decodeCreateSubmissionAdminRequest(r) + if err != nil { + err = &ogenerrors.DecodeRequestError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeRequest", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + defer func() { + if err := close(); err != nil { + recordError("CloseRequest", err) + } + }() + + var response *OperationID + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: CreateSubmissionAdminOperation, + OperationSummary: "Trigger the validator to create a new submission", + OperationID: "createSubmissionAdmin", + Body: request, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = *SubmissionTriggerCreate + Params = struct{} + Response = *OperationID + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.CreateSubmissionAdmin(ctx, request) + return response, err + }, + ) + } else { + response, err = s.h.CreateSubmissionAdmin(ctx, request) + } + if err != nil { + if errRes, ok := errors.Into[*ErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeCreateSubmissionAdminResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + // handleCreateSubmissionAuditCommentRequest handles createSubmissionAuditComment operation. // // Post a comment to the audit log. diff --git a/pkg/api/oas_operations_gen.go b/pkg/api/oas_operations_gen.go index bbc161d..bb64e95 100644 --- a/pkg/api/oas_operations_gen.go +++ b/pkg/api/oas_operations_gen.go @@ -31,6 +31,7 @@ const ( CreateScriptOperation OperationName = "CreateScript" CreateScriptPolicyOperation OperationName = "CreateScriptPolicy" CreateSubmissionOperation OperationName = "CreateSubmission" + CreateSubmissionAdminOperation OperationName = "CreateSubmissionAdmin" CreateSubmissionAuditCommentOperation OperationName = "CreateSubmissionAuditComment" DeleteScriptOperation OperationName = "DeleteScript" DeleteScriptPolicyOperation OperationName = "DeleteScriptPolicy" diff --git a/pkg/api/oas_request_decoders_gen.go b/pkg/api/oas_request_decoders_gen.go index 72c4ed7..a53e8e7 100644 --- a/pkg/api/oas_request_decoders_gen.go +++ b/pkg/api/oas_request_decoders_gen.go @@ -334,6 +334,77 @@ func (s *Server) decodeCreateSubmissionRequest(r *http.Request) ( } } +func (s *Server) decodeCreateSubmissionAdminRequest(r *http.Request) ( + req *SubmissionTriggerCreate, + close func() error, + rerr error, +) { + var closers []func() error + close = func() error { + var merr error + // Close in reverse order, to match defer behavior. + for i := len(closers) - 1; i >= 0; i-- { + c := closers[i] + merr = multierr.Append(merr, c()) + } + return merr + } + defer func() { + if rerr != nil { + rerr = multierr.Append(rerr, close()) + } + }() + ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, close, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + if r.ContentLength == 0 { + return req, close, validate.ErrBodyRequired + } + buf, err := io.ReadAll(r.Body) + if err != nil { + return req, close, err + } + + if len(buf) == 0 { + return req, close, validate.ErrBodyRequired + } + + d := jx.DecodeBytes(buf) + + var request SubmissionTriggerCreate + if err := func() error { + if err := request.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return req, close, err + } + if err := func() error { + if err := request.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return req, close, errors.Wrap(err, "validate") + } + return &request, close, nil + default: + return req, close, validate.InvalidContentType(ct) + } +} + func (s *Server) decodeCreateSubmissionAuditCommentRequest(r *http.Request) ( req CreateSubmissionAuditCommentReq, close func() error, diff --git a/pkg/api/oas_request_encoders_gen.go b/pkg/api/oas_request_encoders_gen.go index 84923a2..09c700c 100644 --- a/pkg/api/oas_request_encoders_gen.go +++ b/pkg/api/oas_request_encoders_gen.go @@ -77,6 +77,20 @@ func encodeCreateSubmissionRequest( return nil } +func encodeCreateSubmissionAdminRequest( + req *SubmissionTriggerCreate, + r *http.Request, +) error { + const contentType = "application/json" + e := new(jx.Encoder) + { + req.Encode(e) + } + encoded := e.Bytes() + ht.SetBody(r, bytes.NewReader(encoded), contentType) + return nil +} + func encodeCreateSubmissionAuditCommentRequest( req CreateSubmissionAuditCommentReq, r *http.Request, diff --git a/pkg/api/oas_response_decoders_gen.go b/pkg/api/oas_response_decoders_gen.go index d92f378..a017f83 100644 --- a/pkg/api/oas_response_decoders_gen.go +++ b/pkg/api/oas_response_decoders_gen.go @@ -1679,6 +1679,107 @@ func decodeCreateSubmissionResponse(resp *http.Response) (res *OperationID, _ er return res, errors.Wrap(defRes, "error") } +func decodeCreateSubmissionAdminResponse(resp *http.Response) (res *OperationID, _ error) { + switch resp.StatusCode { + case 201: + // Code 201. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response OperationID + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *ErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &ErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + func decodeCreateSubmissionAuditCommentResponse(resp *http.Response) (res *CreateSubmissionAuditCommentNoContent, _ error) { switch resp.StatusCode { case 204: diff --git a/pkg/api/oas_response_encoders_gen.go b/pkg/api/oas_response_encoders_gen.go index 33e5527..151b325 100644 --- a/pkg/api/oas_response_encoders_gen.go +++ b/pkg/api/oas_response_encoders_gen.go @@ -216,6 +216,20 @@ func encodeCreateSubmissionResponse(response *OperationID, w http.ResponseWriter return nil } +func encodeCreateSubmissionAdminResponse(response *OperationID, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(201) + span.SetStatus(codes.Ok, http.StatusText(201)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + func encodeCreateSubmissionAuditCommentResponse(response *CreateSubmissionAuditCommentNoContent, w http.ResponseWriter, span trace.Span) error { w.WriteHeader(204) span.SetStatus(codes.Ok, http.StatusText(204)) diff --git a/pkg/api/oas_router_gen.go b/pkg/api/oas_router_gen.go index 4244ce2..00ff848 100644 --- a/pkg/api/oas_router_gen.go +++ b/pkg/api/oas_router_gen.go @@ -870,6 +870,26 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } switch elem[0] { + case '-': // Prefix: "-admin" + + if l := len("-admin"); len(elem) >= l && elem[0:l] == "-admin" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "POST": + s.handleCreateSubmissionAdminRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, "POST") + } + + return + } + case '/': // Prefix: "/" if l := len("/"); len(elem) >= l && elem[0:l] == "/" { @@ -2315,6 +2335,30 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { } } switch elem[0] { + case '-': // Prefix: "-admin" + + if l := len("-admin"); len(elem) >= l && elem[0:l] == "-admin" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "POST": + r.name = CreateSubmissionAdminOperation + r.summary = "Trigger the validator to create a new submission" + r.operationID = "createSubmissionAdmin" + r.pathPattern = "/submissions-admin" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + case '/': // Prefix: "/" if l := len("/"); len(elem) >= l && elem[0:l] == "/" { diff --git a/pkg/api/oas_server_gen.go b/pkg/api/oas_server_gen.go index aa46501..08df749 100644 --- a/pkg/api/oas_server_gen.go +++ b/pkg/api/oas_server_gen.go @@ -160,6 +160,12 @@ type Handler interface { // // POST /submissions CreateSubmission(ctx context.Context, req *SubmissionTriggerCreate) (*OperationID, error) + // CreateSubmissionAdmin implements createSubmissionAdmin operation. + // + // Trigger the validator to create a new submission. + // + // POST /submissions-admin + CreateSubmissionAdmin(ctx context.Context, req *SubmissionTriggerCreate) (*OperationID, error) // CreateSubmissionAuditComment implements createSubmissionAuditComment operation. // // Post a comment to the audit log. diff --git a/pkg/api/oas_unimplemented_gen.go b/pkg/api/oas_unimplemented_gen.go index 239065f..9f0e804 100644 --- a/pkg/api/oas_unimplemented_gen.go +++ b/pkg/api/oas_unimplemented_gen.go @@ -240,6 +240,15 @@ func (UnimplementedHandler) CreateSubmission(ctx context.Context, req *Submissio return r, ht.ErrNotImplemented } +// CreateSubmissionAdmin implements createSubmissionAdmin operation. +// +// Trigger the validator to create a new submission. +// +// POST /submissions-admin +func (UnimplementedHandler) CreateSubmissionAdmin(ctx context.Context, req *SubmissionTriggerCreate) (r *OperationID, _ error) { + return r, ht.ErrNotImplemented +} + // CreateSubmissionAuditComment implements createSubmissionAuditComment operation. // // Post a comment to the audit log. diff --git a/pkg/internal/oas_json_gen.go b/pkg/internal/oas_json_gen.go index e89d035..9a74a1a 100644 --- a/pkg/internal/oas_json_gen.go +++ b/pkg/internal/oas_json_gen.go @@ -1323,9 +1323,17 @@ func (s *SubmissionCreate) encodeFields(e *jx.Encoder) { e.FieldStart("AssetVersion") e.Int64(s.AssetVersion) } + { + e.FieldStart("Status") + e.UInt32(s.Status) + } + { + e.FieldStart("Roles") + e.UInt32(s.Roles) + } } -var jsonFieldsNameOfSubmissionCreate = [7]string{ +var jsonFieldsNameOfSubmissionCreate = [9]string{ 0: "OperationID", 1: "AssetOwner", 2: "DisplayName", @@ -1333,6 +1341,8 @@ var jsonFieldsNameOfSubmissionCreate = [7]string{ 4: "GameID", 5: "AssetID", 6: "AssetVersion", + 7: "Status", + 8: "Roles", } // Decode decodes SubmissionCreate from json. @@ -1340,7 +1350,7 @@ func (s *SubmissionCreate) Decode(d *jx.Decoder) error { if s == nil { return errors.New("invalid: unable to decode SubmissionCreate to nil") } - var requiredBitSet [1]uint8 + var requiredBitSet [2]uint8 if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { switch string(k) { @@ -1428,6 +1438,30 @@ func (s *SubmissionCreate) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"AssetVersion\"") } + case "Status": + requiredBitSet[0] |= 1 << 7 + if err := func() error { + v, err := d.UInt32() + s.Status = uint32(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"Status\"") + } + case "Roles": + requiredBitSet[1] |= 1 << 0 + if err := func() error { + v, err := d.UInt32() + s.Roles = uint32(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"Roles\"") + } default: return d.Skip() } @@ -1437,8 +1471,9 @@ func (s *SubmissionCreate) Decode(d *jx.Decoder) error { } // Validate required fields. var failures []validate.FieldError - for i, mask := range [1]uint8{ - 0b01111111, + for i, mask := range [2]uint8{ + 0b11111111, + 0b00000001, } { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { // Mask only required fields and check equality to mask using XOR. diff --git a/pkg/internal/oas_schemas_gen.go b/pkg/internal/oas_schemas_gen.go index cb3ca30..86ef488 100644 --- a/pkg/internal/oas_schemas_gen.go +++ b/pkg/internal/oas_schemas_gen.go @@ -594,6 +594,8 @@ type SubmissionCreate struct { GameID int32 `json:"GameID"` AssetID int64 `json:"AssetID"` AssetVersion int64 `json:"AssetVersion"` + Status uint32 `json:"Status"` + Roles uint32 `json:"Roles"` } // GetOperationID returns the value of OperationID. @@ -631,6 +633,16 @@ func (s *SubmissionCreate) GetAssetVersion() int64 { return s.AssetVersion } +// GetStatus returns the value of Status. +func (s *SubmissionCreate) GetStatus() uint32 { + return s.Status +} + +// GetRoles returns the value of Roles. +func (s *SubmissionCreate) GetRoles() uint32 { + return s.Roles +} + // SetOperationID sets the value of OperationID. func (s *SubmissionCreate) SetOperationID(val int32) { s.OperationID = val @@ -666,6 +678,16 @@ func (s *SubmissionCreate) SetAssetVersion(val int64) { s.AssetVersion = val } +// SetStatus sets the value of Status. +func (s *SubmissionCreate) SetStatus(val uint32) { + s.Status = val +} + +// SetRoles sets the value of Roles. +func (s *SubmissionCreate) SetRoles(val uint32) { + s.Roles = val +} + // Ref: #/components/schemas/SubmissionID type SubmissionID struct { SubmissionID int64 `json:"SubmissionID"` diff --git a/pkg/internal/oas_validators_gen.go b/pkg/internal/oas_validators_gen.go index 159c9f4..86e8c6a 100644 --- a/pkg/internal/oas_validators_gen.go +++ b/pkg/internal/oas_validators_gen.go @@ -881,6 +881,26 @@ func (s *SubmissionCreate) Validate() error { Error: err, }) } + if err := func() error { + if err := (validate.Int{ + MinSet: true, + Min: 0, + MaxSet: true, + Max: 9, + MinExclusive: false, + MaxExclusive: false, + MultipleOfSet: false, + MultipleOf: 0, + }).Validate(int64(s.Status)); err != nil { + return errors.Wrap(err, "int") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "Status", + Error: err, + }) + } if len(failures) > 0 { return &validate.Error{Fields: failures} } diff --git a/pkg/model/nats.go b/pkg/model/nats.go index 0d422af..a0882e6 100644 --- a/pkg/model/nats.go +++ b/pkg/model/nats.go @@ -12,6 +12,8 @@ type CreateSubmissionRequest struct { DisplayName string Creator string GameID uint32 + Status uint32 + Roles uint32 } type CreateMapfixRequest struct { diff --git a/pkg/service/submissions.go b/pkg/service/submissions.go index 955f5eb..3ec4516 100644 --- a/pkg/service/submissions.go +++ b/pkg/service/submissions.go @@ -106,6 +106,84 @@ func (svc *Service) CreateSubmission(ctx context.Context, request *api.Submissio DisplayName: request.DisplayName, Creator: request.Creator, GameID: uint32(request.GameID), + Status: uint32(model.SubmissionStatusUnderConstruction), + Roles: uint32(RolesEmpty), + } + + j, err := json.Marshal(create_request) + if err != nil { + return nil, err + } + + _, err = svc.Nats.Publish("maptest.submissions.create", []byte(j)) + if err != nil { + return nil, err + } + + return &api.OperationID{ + OperationID: operation.ID, + }, nil +} +// POST /submissions-admin +func (svc *Service) CreateSubmissionAdmin(ctx context.Context, request *api.SubmissionTriggerCreate) (*api.OperationID, error) { + // sanitization + if request.AssetID<0{ + return nil, ErrNegativeID + } + var ModelID=uint64(request.AssetID); + + userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) + if !ok { + return nil, ErrUserInfo + } + + userId, err := userInfo.GetUserID() + if err != nil { + return nil, err + } + + roles, err := userInfo.GetRoles() + if err != nil { + return nil, err + } + + // check if caller has required role + has_role := roles & RolesSubmissionReview == RolesSubmissionReview + if !has_role { + return nil, ErrPermissionDeniedNeedRoleSubmissionReview + } + + // Check if too many operations have been created recently + { + count, err := svc.DB.Operations().CountSince(ctx, + int64(userId), + time.Now().Add(-CreateSubmissionRecencyWindow), + ) + if err != nil { + return nil, err + } + + if CreateSubmissionRateLimit < count { + return nil, ErrCreateSubmissionRateLimit + } + } + + operation, err := svc.DB.Operations().Create(ctx, model.Operation{ + Owner: userId, + StatusID: model.OperationStatusCreated, + }) + if err != nil { + return nil, err + } + + create_request := model.CreateSubmissionRequest{ + OperationID: operation.ID, + ModelID: ModelID, + DisplayName: request.DisplayName, + Creator: request.Creator, + GameID: uint32(request.GameID), + Status: uint32(model.SubmissionStatusChangesRequested), + Roles: uint32(roles), } j, err := json.Marshal(create_request) @@ -506,9 +584,15 @@ func (svc *Service) ActionSubmissionTriggerSubmit(ctx context.Context, params ap return err } + has_submission_review, err := userInfo.HasRoleSubmissionReview() + if err != nil { + return err + } + // check if caller is the submitter - has_role := userId == submission.Submitter - if !has_role { + is_submitter := userId == submission.Submitter + // neither = deny + if !is_submitter && !has_submission_review { return ErrPermissionDeniedNotSubmitter } diff --git a/pkg/service_internal/submissions.go b/pkg/service_internal/submissions.go index ddbe54f..1ce1e4c 100644 --- a/pkg/service_internal/submissions.go +++ b/pkg/service_internal/submissions.go @@ -9,6 +9,7 @@ import ( "git.itzana.me/strafesnet/maps-service/pkg/datastore" internal "git.itzana.me/strafesnet/maps-service/pkg/internal" "git.itzana.me/strafesnet/maps-service/pkg/model" + "git.itzana.me/strafesnet/maps-service/pkg/service" ) var( @@ -337,6 +338,8 @@ func (svc *Service) CreateSubmission(ctx context.Context, request *internal.Subm var Submitter=uint64(request.AssetOwner); var AssetID=uint64(request.AssetID); var AssetVersion=uint64(request.AssetVersion); + var Status=model.SubmissionStatus(request.Status); + var roles=service.Roles(request.Roles); // Check if an active submission with the same asset id exists { @@ -362,8 +365,11 @@ func (svc *Service) CreateSubmission(ctx context.Context, request *internal.Subm } // check if user owns asset - // TODO: allow bypass by admin - if operation.Owner != Submitter { + is_submitter := operation.Owner == Submitter + // check if user is map admin + has_submission_review := roles & service.RolesSubmissionReview == service.RolesSubmissionReview + // if neither, u not allowed + if !is_submitter && !has_submission_review { return nil, ErrNotAssetOwner } @@ -376,7 +382,7 @@ func (svc *Service) CreateSubmission(ctx context.Context, request *internal.Subm AssetID: AssetID, AssetVersion: AssetVersion, Completed: false, - StatusID: model.SubmissionStatusUnderConstruction, + StatusID: Status, }) if err != nil { return nil, err diff --git a/validation/api/src/types.rs b/validation/api/src/types.rs index eb4e87e..c5a1988 100644 --- a/validation/api/src/types.rs +++ b/validation/api/src/types.rs @@ -91,6 +91,8 @@ pub struct CreateSubmissionRequest<'a>{ pub GameID:i32, pub AssetID:u64, pub AssetVersion:u64, + pub Status:u32, + pub Roles:u32, } #[allow(nonstandard_style)] #[derive(Clone,Debug,serde::Deserialize)] diff --git a/validation/src/create_submission.rs b/validation/src/create_submission.rs index 06485d4..3cfc3cc 100644 --- a/validation/src/create_submission.rs +++ b/validation/src/create_submission.rs @@ -45,6 +45,8 @@ impl crate::message_handler::MessageHandler{ GameID:game_id as i32, AssetID:create_info.ModelID, AssetVersion:create_request.AssetVersion, + Status:create_info.Status, + Roles:create_info.Roles, }).await.map_err(Error::ApiActionSubmissionCreate)?; Ok(()) diff --git a/validation/src/nats_types.rs b/validation/src/nats_types.rs index 357e69b..6d8d712 100644 --- a/validation/src/nats_types.rs +++ b/validation/src/nats_types.rs @@ -13,6 +13,9 @@ pub struct CreateSubmissionRequest{ pub DisplayName:String, pub Creator:String, pub GameID:u32, + // initial status is passed back on create + pub Status:u32, + pub Roles:u32, } #[allow(nonstandard_style)] diff --git a/web/src/app/admin-submit/(styles)/page.scss b/web/src/app/admin-submit/(styles)/page.scss new file mode 100644 index 0000000..23bf59a --- /dev/null +++ b/web/src/app/admin-submit/(styles)/page.scss @@ -0,0 +1,54 @@ +@use "../../globals.scss"; + +::placeholder { + color: var(--placeholder-text) +} + +.form-spacer { + margin-bottom: 20px; + + &:last-of-type { + margin-top: 15px; + } +} + +#target-asset-radio { + color: var(--text-color); + font-size: globals.$form-label-fontsize; +} + +.form-field { + width: 850px; + + & label, & input { + color: var(--text-color); + } + & fieldset { + border-color: rgb(100,100,100); + } + & span { + color: white; + } +} + +main { + display: grid; + justify-content: center; + align-items: center; + margin-inline: auto; + width: 700px; +} + +header h1 { + text-align: center; + color: var(--text-color); +} + +form { + display: grid; + gap: 25px; + + fieldset { + border: blue + } +} diff --git a/web/src/app/admin-submit/_game.tsx b/web/src/app/admin-submit/_game.tsx new file mode 100644 index 0000000..e754601 --- /dev/null +++ b/web/src/app/admin-submit/_game.tsx @@ -0,0 +1,65 @@ +import { FormControl, Select, InputLabel, MenuItem } from "@mui/material"; +import { styled } from '@mui/material/styles'; +import InputBase from '@mui/material/InputBase'; +import React from "react"; +import { SelectChangeEvent } from "@mui/material"; + +// TODO: Properly style everything instead of pasting 🤚 + +type GameSelectionProps = { + game: number; + setGame: React.Dispatch>; +}; + +const BootstrapInput = styled(InputBase)(({ theme }) => ({ + 'label + &': { + marginTop: theme.spacing(3), + }, + '& .MuiInputBase-input': { + backgroundColor: '#0000', + color: '#FFF', + border: '1px solid rgba(175, 175, 175, 0.66)', + fontSize: 16, + padding: '10px 26px 10px 12px', + transition: theme.transitions.create(['border-color', 'box-shadow']), + fontFamily: [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + ].join(','), + '&:focus': { + borderRadius: 4, + borderColor: '#80bdff', + boxShadow: '0 0 0 0.2rem rgba(0,123,255,.25)', + }, + }, + })); + +export default function GameSelection({ game, setGame }: GameSelectionProps) { + const handleChange = (event: SelectChangeEvent) => { + setGame(Number(event.target.value)); // TODO: Change later!! there's 100% a proper way of doing this + }; + + return ( + + Game + + + ); +} \ No newline at end of file diff --git a/web/src/app/admin-submit/page.tsx b/web/src/app/admin-submit/page.tsx new file mode 100644 index 0000000..a7a5170 --- /dev/null +++ b/web/src/app/admin-submit/page.tsx @@ -0,0 +1,90 @@ +"use client" + +import { Button, TextField } from "@mui/material" + +import GameSelection from "./_game"; +import SendIcon from '@mui/icons-material/Send'; +import Webpage from "@/app/_components/webpage" +import React, { useState } from "react"; + +import "./(styles)/page.scss" + +interface SubmissionPayload { + AssetID: number; + DisplayName: string; + Creator: string; + GameID: number; +} +interface IdResponse { + OperationID: number; +} + +export default function SubmissionInfoPage() { + const [game, setGame] = useState(1); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const form = event.currentTarget; + const formData = new FormData(form); + + const payload: SubmissionPayload = { + DisplayName: (formData.get("display-name") as string) ?? "unknown", // TEMPORARY! TODO: Change + Creator: (formData.get("creator") as string) ?? "unknown", // TEMPORARY! TODO: Change + GameID: game, + AssetID: Number((formData.get("asset-id") as string) ?? "0"), + }; + + console.log(payload) + console.log(JSON.stringify(payload)) + + try { + // Send the POST request + const response = await fetch("/api/submissions-admin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + // Check if the HTTP request was successful + if (!response.ok) { + const errorDetails = await response.text(); + + // Throw an error with detailed information + throw new Error(`HTTP error! status: ${response.status}, details: ${errorDetails}`); + } + + // Allow any HTTP status + const id_response:IdResponse = await response.json(); + + // navigate to newly created submission + window.location.assign(`/operations/${id_response.OperationID}`) + + } catch (error) { + console.error("Error submitting data:", error); + } + }; + + return ( + +
+
+

Submit New Map on Behalf of Another User (Admin)

+ +
+
+ + + + + + + +
+
+ ) +} diff --git a/web/src/app/submissions/[submissionId]/_reviewButtons.tsx b/web/src/app/submissions/[submissionId]/_reviewButtons.tsx index 2ac0ad8..ba34c54 100644 --- a/web/src/app/submissions/[submissionId]/_reviewButtons.tsx +++ b/web/src/app/submissions/[submissionId]/_reviewButtons.tsx @@ -119,6 +119,10 @@ export default function ReviewButtons(props: ReviewId) { } if (roles&RolesConstants.SubmissionReview) { + // you can force submit a map in ChangesRequested status + if (!is_submitter && submissionStatus === SubmissionStatus.ChangesRequested) { + visibleButtons.push({ action: ReviewActions.Submit, color: "info", submissionId }); + } // you can't review your own submission! // note that this means there needs to be more than one person with SubmissionReview if (!is_submitter && submissionStatus === SubmissionStatus.Submitted) { diff --git a/web/src/app/ts/AuditEvent.ts b/web/src/app/ts/AuditEvent.ts index 18f91a0..f9882a1 100644 --- a/web/src/app/ts/AuditEvent.ts +++ b/web/src/app/ts/AuditEvent.ts @@ -1,7 +1,7 @@ import { SubmissionStatusToString } from "./Submission"; // Shared audit event types -export enum AuditEventType { +export const enum AuditEventType { Action = 0, Comment = 1, ChangeModel = 2,