diff --git a/README.md b/README.md index 75dc884..7930a81 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Prerequisite: bun installed The environment variables `API_HOST` and `AUTH_HOST` will need to be set for the middleware. Example `.env` in web's root: ``` -API_HOST="http://localhost:8082/v1/" +API_HOST="http://localhost:8082/" AUTH_HOST="http://localhost:8083/" ``` diff --git a/openapi.yaml b/openapi.yaml index 2879ae8..8b656e4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -384,6 +384,23 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /mapfixes/{MapfixID}/status/bypass-submit: + post: + summary: Role Reviewer changes status from ChangesRequested -> Submitted + operationId: actionMapfixBypassSubmit + tags: + - Mapfixes + parameters: + - $ref: '#/components/parameters/MapfixID' + responses: + "204": + description: Successful response + default: + description: General Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /mapfixes/{MapfixID}/status/reset-submitting: post: summary: Role Submitter manually resets submitting softlock and changes status from Submitting -> UnderConstruction @@ -816,6 +833,23 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /submissions/{SubmissionID}/status/bypass-submit: + post: + summary: Role Reviewer changes status from ChangesRequested -> Submitted + operationId: actionSubmissionBypassSubmit + tags: + - Submissions + parameters: + - $ref: '#/components/parameters/SubmissionID' + responses: + "204": + description: Successful response + default: + description: General Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /submissions/{SubmissionID}/status/reset-submitting: post: summary: Role Submitter manually resets submitting softlock and changes status from Submitting -> UnderConstruction diff --git a/pkg/api/oas_client_gen.go b/pkg/api/oas_client_gen.go index 7951723..b5fa438 100644 --- a/pkg/api/oas_client_gen.go +++ b/pkg/api/oas_client_gen.go @@ -35,6 +35,12 @@ type Invoker interface { // // POST /mapfixes/{MapfixID}/status/reset-validating ActionMapfixAccepted(ctx context.Context, params ActionMapfixAcceptedParams) error + // ActionMapfixBypassSubmit invokes actionMapfixBypassSubmit operation. + // + // Role Reviewer changes status from ChangesRequested -> Submitted. + // + // POST /mapfixes/{MapfixID}/status/bypass-submit + ActionMapfixBypassSubmit(ctx context.Context, params ActionMapfixBypassSubmitParams) error // ActionMapfixReject invokes actionMapfixReject operation. // // Role Reviewer changes status from Submitted -> Rejected. @@ -96,6 +102,12 @@ type Invoker interface { // // POST /submissions/{SubmissionID}/status/reset-validating ActionSubmissionAccepted(ctx context.Context, params ActionSubmissionAcceptedParams) error + // ActionSubmissionBypassSubmit invokes actionSubmissionBypassSubmit operation. + // + // Role Reviewer changes status from ChangesRequested -> Submitted. + // + // POST /submissions/{SubmissionID}/status/bypass-submit + ActionSubmissionBypassSubmit(ctx context.Context, params ActionSubmissionBypassSubmitParams) error // ActionSubmissionReject invokes actionSubmissionReject operation. // // Role Reviewer changes status from Submitted -> Rejected. @@ -518,6 +530,130 @@ func (c *Client) sendActionMapfixAccepted(ctx context.Context, params ActionMapf return result, nil } +// ActionMapfixBypassSubmit invokes actionMapfixBypassSubmit operation. +// +// Role Reviewer changes status from ChangesRequested -> Submitted. +// +// POST /mapfixes/{MapfixID}/status/bypass-submit +func (c *Client) ActionMapfixBypassSubmit(ctx context.Context, params ActionMapfixBypassSubmitParams) error { + _, err := c.sendActionMapfixBypassSubmit(ctx, params) + return err +} + +func (c *Client) sendActionMapfixBypassSubmit(ctx context.Context, params ActionMapfixBypassSubmitParams) (res *ActionMapfixBypassSubmitNoContent, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("actionMapfixBypassSubmit"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/mapfixes/{MapfixID}/status/bypass-submit"), + } + + // 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, ActionMapfixBypassSubmitOperation, + 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 [3]string + pathParts[0] = "/mapfixes/" + { + // Encode "MapfixID" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "MapfixID", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.Int64ToString(params.MapfixID)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + pathParts[2] = "/status/bypass-submit" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + { + type bitset = [1]uint8 + var satisfied bitset + { + stage = "Security:CookieAuth" + switch err := c.securityCookieAuth(ctx, ActionMapfixBypassSubmitOperation, 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 := decodeActionMapfixBypassSubmitResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + // ActionMapfixReject invokes actionMapfixReject operation. // // Role Reviewer changes status from Submitted -> Rejected. @@ -1759,6 +1895,130 @@ func (c *Client) sendActionSubmissionAccepted(ctx context.Context, params Action return result, nil } +// ActionSubmissionBypassSubmit invokes actionSubmissionBypassSubmit operation. +// +// Role Reviewer changes status from ChangesRequested -> Submitted. +// +// POST /submissions/{SubmissionID}/status/bypass-submit +func (c *Client) ActionSubmissionBypassSubmit(ctx context.Context, params ActionSubmissionBypassSubmitParams) error { + _, err := c.sendActionSubmissionBypassSubmit(ctx, params) + return err +} + +func (c *Client) sendActionSubmissionBypassSubmit(ctx context.Context, params ActionSubmissionBypassSubmitParams) (res *ActionSubmissionBypassSubmitNoContent, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("actionSubmissionBypassSubmit"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/submissions/{SubmissionID}/status/bypass-submit"), + } + + // 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, ActionSubmissionBypassSubmitOperation, + 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 [3]string + pathParts[0] = "/submissions/" + { + // Encode "SubmissionID" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "SubmissionID", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.Int64ToString(params.SubmissionID)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + pathParts[2] = "/status/bypass-submit" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + { + type bitset = [1]uint8 + var satisfied bitset + { + stage = "Security:CookieAuth" + switch err := c.securityCookieAuth(ctx, ActionSubmissionBypassSubmitOperation, 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 := decodeActionSubmissionBypassSubmitResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + // ActionSubmissionReject invokes actionSubmissionReject operation. // // Role Reviewer changes status from Submitted -> Rejected. diff --git a/pkg/api/oas_handlers_gen.go b/pkg/api/oas_handlers_gen.go index 13081b7..a2677d0 100644 --- a/pkg/api/oas_handlers_gen.go +++ b/pkg/api/oas_handlers_gen.go @@ -225,6 +225,201 @@ func (s *Server) handleActionMapfixAcceptedRequest(args [1]string, argsEscaped b } } +// handleActionMapfixBypassSubmitRequest handles actionMapfixBypassSubmit operation. +// +// Role Reviewer changes status from ChangesRequested -> Submitted. +// +// POST /mapfixes/{MapfixID}/status/bypass-submit +func (s *Server) handleActionMapfixBypassSubmitRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("actionMapfixBypassSubmit"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/mapfixes/{MapfixID}/status/bypass-submit"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), ActionMapfixBypassSubmitOperation, + 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: ActionMapfixBypassSubmitOperation, + ID: "actionMapfixBypassSubmit", + } + ) + { + type bitset = [1]uint8 + var satisfied bitset + { + sctx, ok, err := s.securityCookieAuth(ctx, ActionMapfixBypassSubmitOperation, 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 + } + } + params, err := decodeActionMapfixBypassSubmitParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var response *ActionMapfixBypassSubmitNoContent + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: ActionMapfixBypassSubmitOperation, + OperationSummary: "Role Reviewer changes status from ChangesRequested -> Submitted", + OperationID: "actionMapfixBypassSubmit", + Body: nil, + Params: middleware.Parameters{ + { + Name: "MapfixID", + In: "path", + }: params.MapfixID, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = ActionMapfixBypassSubmitParams + Response = *ActionMapfixBypassSubmitNoContent + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackActionMapfixBypassSubmitParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + err = s.h.ActionMapfixBypassSubmit(ctx, params) + return response, err + }, + ) + } else { + err = s.h.ActionMapfixBypassSubmit(ctx, params) + } + 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 := encodeActionMapfixBypassSubmitResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + // handleActionMapfixRejectRequest handles actionMapfixReject operation. // // Role Reviewer changes status from Submitted -> Rejected. @@ -2176,6 +2371,201 @@ func (s *Server) handleActionSubmissionAcceptedRequest(args [1]string, argsEscap } } +// handleActionSubmissionBypassSubmitRequest handles actionSubmissionBypassSubmit operation. +// +// Role Reviewer changes status from ChangesRequested -> Submitted. +// +// POST /submissions/{SubmissionID}/status/bypass-submit +func (s *Server) handleActionSubmissionBypassSubmitRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("actionSubmissionBypassSubmit"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/submissions/{SubmissionID}/status/bypass-submit"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), ActionSubmissionBypassSubmitOperation, + 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: ActionSubmissionBypassSubmitOperation, + ID: "actionSubmissionBypassSubmit", + } + ) + { + type bitset = [1]uint8 + var satisfied bitset + { + sctx, ok, err := s.securityCookieAuth(ctx, ActionSubmissionBypassSubmitOperation, 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 + } + } + params, err := decodeActionSubmissionBypassSubmitParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var response *ActionSubmissionBypassSubmitNoContent + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: ActionSubmissionBypassSubmitOperation, + OperationSummary: "Role Reviewer changes status from ChangesRequested -> Submitted", + OperationID: "actionSubmissionBypassSubmit", + Body: nil, + Params: middleware.Parameters{ + { + Name: "SubmissionID", + In: "path", + }: params.SubmissionID, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = ActionSubmissionBypassSubmitParams + Response = *ActionSubmissionBypassSubmitNoContent + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackActionSubmissionBypassSubmitParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + err = s.h.ActionSubmissionBypassSubmit(ctx, params) + return response, err + }, + ) + } else { + err = s.h.ActionSubmissionBypassSubmit(ctx, params) + } + 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 := encodeActionSubmissionBypassSubmitResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + // handleActionSubmissionRejectRequest handles actionSubmissionReject operation. // // Role Reviewer changes status from Submitted -> Rejected. diff --git a/pkg/api/oas_operations_gen.go b/pkg/api/oas_operations_gen.go index bb64e95..0ed67d8 100644 --- a/pkg/api/oas_operations_gen.go +++ b/pkg/api/oas_operations_gen.go @@ -7,6 +7,7 @@ type OperationName = string const ( ActionMapfixAcceptedOperation OperationName = "ActionMapfixAccepted" + ActionMapfixBypassSubmitOperation OperationName = "ActionMapfixBypassSubmit" ActionMapfixRejectOperation OperationName = "ActionMapfixReject" ActionMapfixRequestChangesOperation OperationName = "ActionMapfixRequestChanges" ActionMapfixResetSubmittingOperation OperationName = "ActionMapfixResetSubmitting" @@ -17,6 +18,7 @@ const ( ActionMapfixTriggerValidateOperation OperationName = "ActionMapfixTriggerValidate" ActionMapfixValidatedOperation OperationName = "ActionMapfixValidated" ActionSubmissionAcceptedOperation OperationName = "ActionSubmissionAccepted" + ActionSubmissionBypassSubmitOperation OperationName = "ActionSubmissionBypassSubmit" ActionSubmissionRejectOperation OperationName = "ActionSubmissionReject" ActionSubmissionRequestChangesOperation OperationName = "ActionSubmissionRequestChanges" ActionSubmissionResetSubmittingOperation OperationName = "ActionSubmissionResetSubmitting" diff --git a/pkg/api/oas_parameters_gen.go b/pkg/api/oas_parameters_gen.go index f434a5f..bcfd93d 100644 --- a/pkg/api/oas_parameters_gen.go +++ b/pkg/api/oas_parameters_gen.go @@ -98,6 +98,89 @@ func decodeActionMapfixAcceptedParams(args [1]string, argsEscaped bool, r *http. return params, nil } +// ActionMapfixBypassSubmitParams is parameters of actionMapfixBypassSubmit operation. +type ActionMapfixBypassSubmitParams struct { + // The unique identifier for a mapfix. + MapfixID int64 +} + +func unpackActionMapfixBypassSubmitParams(packed middleware.Parameters) (params ActionMapfixBypassSubmitParams) { + { + key := middleware.ParameterKey{ + Name: "MapfixID", + In: "path", + } + params.MapfixID = packed[key].(int64) + } + return params +} + +func decodeActionMapfixBypassSubmitParams(args [1]string, argsEscaped bool, r *http.Request) (params ActionMapfixBypassSubmitParams, _ error) { + // Decode path: MapfixID. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "MapfixID", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToInt64(val) + if err != nil { + return err + } + + params.MapfixID = c + return nil + }(); err != nil { + return err + } + if err := func() error { + if err := (validate.Int{ + MinSet: true, + Min: 0, + MaxSet: false, + Max: 0, + MinExclusive: false, + MaxExclusive: false, + MultipleOfSet: false, + MultipleOf: 0, + }).Validate(int64(params.MapfixID)); err != nil { + return errors.Wrap(err, "int") + } + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "MapfixID", + In: "path", + Err: err, + } + } + return params, nil +} + // ActionMapfixRejectParams is parameters of actionMapfixReject operation. type ActionMapfixRejectParams struct { // The unique identifier for a mapfix. @@ -928,6 +1011,89 @@ func decodeActionSubmissionAcceptedParams(args [1]string, argsEscaped bool, r *h return params, nil } +// ActionSubmissionBypassSubmitParams is parameters of actionSubmissionBypassSubmit operation. +type ActionSubmissionBypassSubmitParams struct { + // The unique identifier for a submission. + SubmissionID int64 +} + +func unpackActionSubmissionBypassSubmitParams(packed middleware.Parameters) (params ActionSubmissionBypassSubmitParams) { + { + key := middleware.ParameterKey{ + Name: "SubmissionID", + In: "path", + } + params.SubmissionID = packed[key].(int64) + } + return params +} + +func decodeActionSubmissionBypassSubmitParams(args [1]string, argsEscaped bool, r *http.Request) (params ActionSubmissionBypassSubmitParams, _ error) { + // Decode path: SubmissionID. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "SubmissionID", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToInt64(val) + if err != nil { + return err + } + + params.SubmissionID = c + return nil + }(); err != nil { + return err + } + if err := func() error { + if err := (validate.Int{ + MinSet: true, + Min: 0, + MaxSet: false, + Max: 0, + MinExclusive: false, + MaxExclusive: false, + MultipleOfSet: false, + MultipleOf: 0, + }).Validate(int64(params.SubmissionID)); err != nil { + return errors.Wrap(err, "int") + } + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "SubmissionID", + In: "path", + Err: err, + } + } + return params, nil +} + // ActionSubmissionRejectParams is parameters of actionSubmissionReject operation. type ActionSubmissionRejectParams struct { // The unique identifier for a submission. diff --git a/pkg/api/oas_request_decoders_gen.go b/pkg/api/oas_request_decoders_gen.go index a53e8e7..24ca5bf 100644 --- a/pkg/api/oas_request_decoders_gen.go +++ b/pkg/api/oas_request_decoders_gen.go @@ -10,7 +10,6 @@ import ( "github.com/go-faster/errors" "github.com/go-faster/jx" - "go.uber.org/multierr" "github.com/ogen-go/ogen/ogenerrors" "github.com/ogen-go/ogen/validate" @@ -27,13 +26,13 @@ func (s *Server) decodeCreateMapfixRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -98,13 +97,13 @@ func (s *Server) decodeCreateMapfixAuditCommentRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -132,13 +131,13 @@ func (s *Server) decodeCreateScriptRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -203,13 +202,13 @@ func (s *Server) decodeCreateScriptPolicyRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -274,13 +273,13 @@ func (s *Server) decodeCreateSubmissionRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -345,13 +344,13 @@ func (s *Server) decodeCreateSubmissionAdminRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -416,13 +415,13 @@ func (s *Server) decodeCreateSubmissionAuditCommentRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -450,13 +449,13 @@ func (s *Server) decodeReleaseSubmissionsRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -554,13 +553,13 @@ func (s *Server) decodeUpdateScriptRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -625,13 +624,13 @@ func (s *Server) decodeUpdateScriptPolicyRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) diff --git a/pkg/api/oas_response_decoders_gen.go b/pkg/api/oas_response_decoders_gen.go index a017f83..f1227af 100644 --- a/pkg/api/oas_response_decoders_gen.go +++ b/pkg/api/oas_response_decoders_gen.go @@ -75,6 +75,66 @@ func decodeActionMapfixAcceptedResponse(resp *http.Response) (res *ActionMapfixA return res, errors.Wrap(defRes, "error") } +func decodeActionMapfixBypassSubmitResponse(resp *http.Response) (res *ActionMapfixBypassSubmitNoContent, _ error) { + switch resp.StatusCode { + case 204: + // Code 204. + return &ActionMapfixBypassSubmitNoContent{}, nil + } + // 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 decodeActionMapfixRejectResponse(resp *http.Response) (res *ActionMapfixRejectNoContent, _ error) { switch resp.StatusCode { case 204: @@ -675,6 +735,66 @@ func decodeActionSubmissionAcceptedResponse(resp *http.Response) (res *ActionSub return res, errors.Wrap(defRes, "error") } +func decodeActionSubmissionBypassSubmitResponse(resp *http.Response) (res *ActionSubmissionBypassSubmitNoContent, _ error) { + switch resp.StatusCode { + case 204: + // Code 204. + return &ActionSubmissionBypassSubmitNoContent{}, nil + } + // 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 decodeActionSubmissionRejectResponse(resp *http.Response) (res *ActionSubmissionRejectNoContent, _ 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 151b325..6279a81 100644 --- a/pkg/api/oas_response_encoders_gen.go +++ b/pkg/api/oas_response_encoders_gen.go @@ -20,6 +20,13 @@ func encodeActionMapfixAcceptedResponse(response *ActionMapfixAcceptedNoContent, return nil } +func encodeActionMapfixBypassSubmitResponse(response *ActionMapfixBypassSubmitNoContent, w http.ResponseWriter, span trace.Span) error { + w.WriteHeader(204) + span.SetStatus(codes.Ok, http.StatusText(204)) + + return nil +} + func encodeActionMapfixRejectResponse(response *ActionMapfixRejectNoContent, w http.ResponseWriter, span trace.Span) error { w.WriteHeader(204) span.SetStatus(codes.Ok, http.StatusText(204)) @@ -90,6 +97,13 @@ func encodeActionSubmissionAcceptedResponse(response *ActionSubmissionAcceptedNo return nil } +func encodeActionSubmissionBypassSubmitResponse(response *ActionSubmissionBypassSubmitNoContent, w http.ResponseWriter, span trace.Span) error { + w.WriteHeader(204) + span.SetStatus(codes.Ok, http.StatusText(204)) + + return nil +} + func encodeActionSubmissionRejectResponse(response *ActionSubmissionRejectNoContent, 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 00ff848..d74b7b5 100644 --- a/pkg/api/oas_router_gen.go +++ b/pkg/api/oas_router_gen.go @@ -250,6 +250,28 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { break } switch elem[0] { + case 'b': // Prefix: "bypass-submit" + + if l := len("bypass-submit"); len(elem) >= l && elem[0:l] == "bypass-submit" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "POST": + s.handleActionMapfixBypassSubmitRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, "POST") + } + + return + } + case 'r': // Prefix: "re" if l := len("re"); len(elem) >= l && elem[0:l] == "re" { @@ -1046,6 +1068,28 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { break } switch elem[0] { + case 'b': // Prefix: "bypass-submit" + + if l := len("bypass-submit"); len(elem) >= l && elem[0:l] == "bypass-submit" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "POST": + s.handleActionSubmissionBypassSubmitRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, "POST") + } + + return + } + case 'r': // Prefix: "re" if l := len("re"); len(elem) >= l && elem[0:l] == "re" { @@ -1621,6 +1665,30 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { break } switch elem[0] { + case 'b': // Prefix: "bypass-submit" + + if l := len("bypass-submit"); len(elem) >= l && elem[0:l] == "bypass-submit" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "POST": + r.name = ActionMapfixBypassSubmitOperation + r.summary = "Role Reviewer changes status from ChangesRequested -> Submitted" + r.operationID = "actionMapfixBypassSubmit" + r.pathPattern = "/mapfixes/{MapfixID}/status/bypass-submit" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + case 'r': // Prefix: "re" if l := len("re"); len(elem) >= l && elem[0:l] == "re" { @@ -2525,6 +2593,30 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { break } switch elem[0] { + case 'b': // Prefix: "bypass-submit" + + if l := len("bypass-submit"); len(elem) >= l && elem[0:l] == "bypass-submit" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "POST": + r.name = ActionSubmissionBypassSubmitOperation + r.summary = "Role Reviewer changes status from ChangesRequested -> Submitted" + r.operationID = "actionSubmissionBypassSubmit" + r.pathPattern = "/submissions/{SubmissionID}/status/bypass-submit" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + case 'r': // Prefix: "re" if l := len("re"); len(elem) >= l && elem[0:l] == "re" { diff --git a/pkg/api/oas_schemas_gen.go b/pkg/api/oas_schemas_gen.go index c55d7b4..6d355f5 100644 --- a/pkg/api/oas_schemas_gen.go +++ b/pkg/api/oas_schemas_gen.go @@ -17,6 +17,9 @@ func (s *ErrorStatusCode) Error() string { // ActionMapfixAcceptedNoContent is response for ActionMapfixAccepted operation. type ActionMapfixAcceptedNoContent struct{} +// ActionMapfixBypassSubmitNoContent is response for ActionMapfixBypassSubmit operation. +type ActionMapfixBypassSubmitNoContent struct{} + // ActionMapfixRejectNoContent is response for ActionMapfixReject operation. type ActionMapfixRejectNoContent struct{} @@ -47,6 +50,9 @@ type ActionMapfixValidatedNoContent struct{} // ActionSubmissionAcceptedNoContent is response for ActionSubmissionAccepted operation. type ActionSubmissionAcceptedNoContent struct{} +// ActionSubmissionBypassSubmitNoContent is response for ActionSubmissionBypassSubmit operation. +type ActionSubmissionBypassSubmitNoContent struct{} + // ActionSubmissionRejectNoContent is response for ActionSubmissionReject operation. type ActionSubmissionRejectNoContent struct{} @@ -182,6 +188,7 @@ func (s *AuditEventEventData) init() AuditEventEventData { type CookieAuth struct { APIKey string + Roles []string } // GetAPIKey returns the value of APIKey. @@ -189,11 +196,21 @@ func (s *CookieAuth) GetAPIKey() string { return s.APIKey } +// GetRoles returns the value of Roles. +func (s *CookieAuth) GetRoles() []string { + return s.Roles +} + // SetAPIKey sets the value of APIKey. func (s *CookieAuth) SetAPIKey(val string) { s.APIKey = val } +// SetRoles sets the value of Roles. +func (s *CookieAuth) SetRoles(val []string) { + s.Roles = val +} + // CreateMapfixAuditCommentNoContent is response for CreateMapfixAuditComment operation. type CreateMapfixAuditCommentNoContent struct{} diff --git a/pkg/api/oas_security_gen.go b/pkg/api/oas_security_gen.go index 5f1eac3..cb72d81 100644 --- a/pkg/api/oas_security_gen.go +++ b/pkg/api/oas_security_gen.go @@ -33,6 +33,51 @@ func findAuthorization(h http.Header, prefix string) (string, bool) { return "", false } +var operationRolesCookieAuth = map[string][]string{ + ActionMapfixAcceptedOperation: []string{}, + ActionMapfixBypassSubmitOperation: []string{}, + ActionMapfixRejectOperation: []string{}, + ActionMapfixRequestChangesOperation: []string{}, + ActionMapfixResetSubmittingOperation: []string{}, + ActionMapfixRetryValidateOperation: []string{}, + ActionMapfixRevokeOperation: []string{}, + ActionMapfixTriggerSubmitOperation: []string{}, + ActionMapfixTriggerUploadOperation: []string{}, + ActionMapfixTriggerValidateOperation: []string{}, + ActionMapfixValidatedOperation: []string{}, + ActionSubmissionAcceptedOperation: []string{}, + ActionSubmissionBypassSubmitOperation: []string{}, + ActionSubmissionRejectOperation: []string{}, + ActionSubmissionRequestChangesOperation: []string{}, + ActionSubmissionResetSubmittingOperation: []string{}, + ActionSubmissionRetryValidateOperation: []string{}, + ActionSubmissionRevokeOperation: []string{}, + ActionSubmissionTriggerSubmitOperation: []string{}, + ActionSubmissionTriggerUploadOperation: []string{}, + ActionSubmissionTriggerValidateOperation: []string{}, + ActionSubmissionValidatedOperation: []string{}, + CreateMapfixOperation: []string{}, + CreateMapfixAuditCommentOperation: []string{}, + CreateScriptOperation: []string{}, + CreateScriptPolicyOperation: []string{}, + CreateSubmissionOperation: []string{}, + CreateSubmissionAdminOperation: []string{}, + CreateSubmissionAuditCommentOperation: []string{}, + DeleteScriptOperation: []string{}, + DeleteScriptPolicyOperation: []string{}, + GetOperationOperation: []string{}, + ReleaseSubmissionsOperation: []string{}, + SessionRolesOperation: []string{}, + SessionUserOperation: []string{}, + SessionValidateOperation: []string{}, + SetMapfixCompletedOperation: []string{}, + SetSubmissionCompletedOperation: []string{}, + UpdateMapfixModelOperation: []string{}, + UpdateScriptOperation: []string{}, + UpdateScriptPolicyOperation: []string{}, + UpdateSubmissionModelOperation: []string{}, +} + func (s *Server) securityCookieAuth(ctx context.Context, operationName OperationName, req *http.Request) (context.Context, bool, error) { var t CookieAuth const parameterName = "session_id" @@ -46,6 +91,7 @@ func (s *Server) securityCookieAuth(ctx context.Context, operationName Operation return nil, false, errors.Wrap(err, "get cookie value") } t.APIKey = value + t.Roles = operationRolesCookieAuth[operationName] rctx, err := s.sec.HandleCookieAuth(ctx, operationName, t) if errors.Is(err, ogenerrors.ErrSkipServerSecurity) { return nil, false, nil diff --git a/pkg/api/oas_server_gen.go b/pkg/api/oas_server_gen.go index 08df749..f2f961b 100644 --- a/pkg/api/oas_server_gen.go +++ b/pkg/api/oas_server_gen.go @@ -14,6 +14,12 @@ type Handler interface { // // POST /mapfixes/{MapfixID}/status/reset-validating ActionMapfixAccepted(ctx context.Context, params ActionMapfixAcceptedParams) error + // ActionMapfixBypassSubmit implements actionMapfixBypassSubmit operation. + // + // Role Reviewer changes status from ChangesRequested -> Submitted. + // + // POST /mapfixes/{MapfixID}/status/bypass-submit + ActionMapfixBypassSubmit(ctx context.Context, params ActionMapfixBypassSubmitParams) error // ActionMapfixReject implements actionMapfixReject operation. // // Role Reviewer changes status from Submitted -> Rejected. @@ -75,6 +81,12 @@ type Handler interface { // // POST /submissions/{SubmissionID}/status/reset-validating ActionSubmissionAccepted(ctx context.Context, params ActionSubmissionAcceptedParams) error + // ActionSubmissionBypassSubmit implements actionSubmissionBypassSubmit operation. + // + // Role Reviewer changes status from ChangesRequested -> Submitted. + // + // POST /submissions/{SubmissionID}/status/bypass-submit + ActionSubmissionBypassSubmit(ctx context.Context, params ActionSubmissionBypassSubmitParams) error // ActionSubmissionReject implements actionSubmissionReject operation. // // Role Reviewer changes status from Submitted -> Rejected. diff --git a/pkg/api/oas_unimplemented_gen.go b/pkg/api/oas_unimplemented_gen.go index 9f0e804..5f935de 100644 --- a/pkg/api/oas_unimplemented_gen.go +++ b/pkg/api/oas_unimplemented_gen.go @@ -22,6 +22,15 @@ func (UnimplementedHandler) ActionMapfixAccepted(ctx context.Context, params Act return ht.ErrNotImplemented } +// ActionMapfixBypassSubmit implements actionMapfixBypassSubmit operation. +// +// Role Reviewer changes status from ChangesRequested -> Submitted. +// +// POST /mapfixes/{MapfixID}/status/bypass-submit +func (UnimplementedHandler) ActionMapfixBypassSubmit(ctx context.Context, params ActionMapfixBypassSubmitParams) error { + return ht.ErrNotImplemented +} + // ActionMapfixReject implements actionMapfixReject operation. // // Role Reviewer changes status from Submitted -> Rejected. @@ -113,6 +122,15 @@ func (UnimplementedHandler) ActionSubmissionAccepted(ctx context.Context, params return ht.ErrNotImplemented } +// ActionSubmissionBypassSubmit implements actionSubmissionBypassSubmit operation. +// +// Role Reviewer changes status from ChangesRequested -> Submitted. +// +// POST /submissions/{SubmissionID}/status/bypass-submit +func (UnimplementedHandler) ActionSubmissionBypassSubmit(ctx context.Context, params ActionSubmissionBypassSubmitParams) error { + return ht.ErrNotImplemented +} + // ActionSubmissionReject implements actionSubmissionReject operation. // // Role Reviewer changes status from Submitted -> Rejected. diff --git a/pkg/internal/oas_request_decoders_gen.go b/pkg/internal/oas_request_decoders_gen.go index a33ac8d..016e05d 100644 --- a/pkg/internal/oas_request_decoders_gen.go +++ b/pkg/internal/oas_request_decoders_gen.go @@ -9,7 +9,6 @@ import ( "github.com/go-faster/errors" "github.com/go-faster/jx" - "go.uber.org/multierr" "github.com/ogen-go/ogen/ogenerrors" "github.com/ogen-go/ogen/validate" @@ -26,13 +25,13 @@ func (s *Server) decodeCreateMapfixRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -97,13 +96,13 @@ func (s *Server) decodeCreateScriptRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -168,13 +167,13 @@ func (s *Server) decodeCreateScriptPolicyRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -239,13 +238,13 @@ func (s *Server) decodeCreateSubmissionRequest(r *http.Request) ( // Close in reverse order, to match defer behavior. for i := len(closers) - 1; i >= 0; i-- { c := closers[i] - merr = multierr.Append(merr, c()) + merr = errors.Join(merr, c()) } return merr } defer func() { if rerr != nil { - rerr = multierr.Append(rerr, close()) + rerr = errors.Join(rerr, close()) } }() ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) diff --git a/pkg/service/audit_events.go b/pkg/service/audit_events.go index 4f28773..1dfffda 100644 --- a/pkg/service/audit_events.go +++ b/pkg/service/audit_events.go @@ -25,15 +25,24 @@ func (svc *Service) CreateMapfixAuditComment(ctx context.Context, req api.Create if err != nil { return err } - if !has_role { - return ErrPermissionDeniedNeedRoleMapfixReview - } userId, err := userInfo.GetUserID() if err != nil { return err } + if !has_role { + // Submitter has special permission to comment on their mapfix + mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID) + if err != nil { + return err + } + + if mapfix.Submitter != userId { + return ErrPermissionDeniedNeedRoleMapfixReview + } + } + data := []byte{} _, err = req.Read(data) if err != nil { @@ -146,15 +155,24 @@ func (svc *Service) CreateSubmissionAuditComment(ctx context.Context, req api.Cr if err != nil { return err } - if !has_role { - return ErrPermissionDeniedNeedRoleSubmissionReview - } userId, err := userInfo.GetUserID() if err != nil { return err } + if !has_role { + // Submitter has special permission to comment on their submission + submission, err := svc.DB.Submissions().Get(ctx, params.SubmissionID) + if err != nil { + return err + } + + if submission.Submitter != userId { + return ErrPermissionDeniedNeedRoleSubmissionReview + } + } + data := []byte{} _, err = req.Read(data) if err != nil { diff --git a/pkg/service/mapfixes.go b/pkg/service/mapfixes.go index 1712afd..4e69b52 100644 --- a/pkg/service/mapfixes.go +++ b/pkg/service/mapfixes.go @@ -539,6 +539,76 @@ func (svc *Service) ActionMapfixTriggerSubmit(ctx context.Context, params api.Ac return nil } +// ActionMapfixBypassSubmit invokes actionMapfixBypassSubmit operation. +// +// Role Reviewer changes status from ChangesRequested -> Submitted. +// +// POST /mapfixes/{MapfixID}/status/bypass-submit +func (svc *Service) ActionMapfixBypassSubmit(ctx context.Context, params api.ActionMapfixBypassSubmitParams) error { + userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) + if !ok { + return ErrUserInfo + } + + // read mapfix (this could be done with a transaction WHERE clause) + mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID) + if err != nil { + return err + } + + userId, err := userInfo.GetUserID() + if err != nil { + return err + } + + // check if caller is the submitter + is_submitter := userId == mapfix.Submitter + if is_submitter { + return ErrAcceptOwnMapfix + } + + has_mapfix_review, err := userInfo.HasRoleMapfixReview() + if err != nil { + return err + } + + if !has_mapfix_review { + return ErrPermissionDeniedNeedRoleMapfixReview + } + + // transaction + target_status := model.MapfixStatusSubmitted + smap := datastore.Optional() + smap.Add("status_id", target_status) + err = svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusChangesRequested}, smap) + if err != nil { + return err + } + + event_data := model.AuditEventDataAction{ + TargetStatus: uint32(target_status), + } + + EventData, err := json.Marshal(event_data) + if err != nil { + return err + } + + _, err = svc.DB.AuditEvents().Create(ctx, model.AuditEvent{ + ID: 0, + User: userId, + ResourceType: model.ResourceMapfix, + ResourceID: params.MapfixID, + EventType: model.AuditEventTypeAction, + EventData: EventData, + }) + if err != nil { + return err + } + + return nil +} + // ActionMapfixResetSubmitting implements actionMapfixResetSubmitting operation. // // Role MapfixReview changes status from Submitting -> UnderConstruction. diff --git a/pkg/service/submissions.go b/pkg/service/submissions.go index 3ec4516..b2f0538 100644 --- a/pkg/service/submissions.go +++ b/pkg/service/submissions.go @@ -20,7 +20,7 @@ var( model.SubmissionStatusSubmitted, model.SubmissionStatusUnderConstruction, } - // limit mapfixes in the pipeline to one per target map + // limit submissions in the pipeline to one per target map ActiveAcceptedSubmissionStatuses = []model.SubmissionStatus{ model.SubmissionStatusUploading, model.SubmissionStatusValidated, @@ -584,16 +584,18 @@ 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 is_submitter := userId == submission.Submitter // neither = deny - if !is_submitter && !has_submission_review { - return ErrPermissionDeniedNotSubmitter + if !is_submitter { + has_submission_review, err := userInfo.HasRoleSubmissionReview() + if err != nil { + return err + } + + if !has_submission_review { + return ErrPermissionDeniedNotSubmitter + } } // transaction @@ -644,6 +646,76 @@ func (svc *Service) ActionSubmissionTriggerSubmit(ctx context.Context, params ap return nil } +// ActionSubmissionBypassSubmit invokes actionSubmissionBypassSubmit operation. +// +// Role Reviewer changes status from ChangesRequested -> Submitted. +// +// POST /submissions/{SubmissionID}/status/bypass-submit +func (svc *Service) ActionSubmissionBypassSubmit(ctx context.Context, params api.ActionSubmissionBypassSubmitParams) error { + userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) + if !ok { + return ErrUserInfo + } + + // read submission (this could be done with a transaction WHERE clause) + submission, err := svc.DB.Submissions().Get(ctx, params.SubmissionID) + if err != nil { + return err + } + + userId, err := userInfo.GetUserID() + if err != nil { + return err + } + + // check if caller is the submitter + is_submitter := userId == submission.Submitter + if is_submitter { + return ErrAcceptOwnSubmission + } + + has_submission_review, err := userInfo.HasRoleSubmissionReview() + if err != nil { + return err + } + + if !has_submission_review { + return ErrPermissionDeniedNeedRoleSubmissionReview + } + + // transaction + target_status := model.SubmissionStatusSubmitted + smap := datastore.Optional() + smap.Add("status_id", target_status) + err = svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusChangesRequested}, smap) + if err != nil { + return err + } + + event_data := model.AuditEventDataAction{ + TargetStatus: uint32(target_status), + } + + EventData, err := json.Marshal(event_data) + if err != nil { + return err + } + + _, err = svc.DB.AuditEvents().Create(ctx, model.AuditEvent{ + ID: 0, + User: userId, + ResourceType: model.ResourceSubmission, + ResourceID: params.SubmissionID, + EventType: model.AuditEventTypeAction, + EventData: EventData, + }) + if err != nil { + return err + } + + return nil +} + // ActionSubmissionResetSubmitting implements actionSubmissionResetSubmitting operation. // // Role SubmissionReview changes status from Submitting -> UnderConstruction. diff --git a/validation/src/check.rs b/validation/src/check.rs index 5ba1c64..2a6569d 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -12,6 +12,7 @@ pub enum Error{ Download(crate::download::Error), ModelFileDecode(ReadDomError), GetRootInstance(GetRootInstanceError), + ToJsonValue(serde_json::Error), } impl std::fmt::Display for Error{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ @@ -47,93 +48,157 @@ impl ModeID{ const BONUS:Self=Self(1); } enum Zone{ - Start(ModeID), - Finish(ModeID), - Anticheat(ModeID), + Start, + Finish, + Anticheat, +} +struct ModeElement{ + zone:Zone, + mode_id:ModeID, } #[allow(dead_code)] pub enum IDParseError{ NoCaptures, - ParseInt(core::num::ParseIntError) + ParseInt(core::num::ParseIntError), } // Parse a Zone from a part name -impl std::str::FromStr for Zone{ +impl std::str::FromStr for ModeElement{ type Err=IDParseError; fn from_str(s:&str)->Result{ match s{ - "MapStart"=>Ok(Self::Start(ModeID::MAIN)), - "MapFinish"=>Ok(Self::Finish(ModeID::MAIN)), - "MapAnticheat"=>Ok(Self::Anticheat(ModeID::MAIN)), - "BonusStart"=>Ok(Self::Start(ModeID::BONUS)), - "BonusFinish"=>Ok(Self::Finish(ModeID::BONUS)), - "BonusAnticheat"=>Ok(Self::Anticheat(ModeID::BONUS)), + "MapStart"=>Ok(Self{zone:Zone::Start,mode_id:ModeID::MAIN}), + "MapFinish"=>Ok(Self{zone:Zone::Finish,mode_id:ModeID::MAIN}), + "MapAnticheat"=>Ok(Self{zone:Zone::Anticheat,mode_id:ModeID::MAIN}), + "BonusStart"=>Ok(Self{zone:Zone::Start,mode_id:ModeID::BONUS}), + "BonusFinish"=>Ok(Self{zone:Zone::Finish,mode_id:ModeID::BONUS}), + "BonusAnticheat"=>Ok(Self{zone:Zone::Anticheat,mode_id:ModeID::BONUS}), other=>{ let bonus_start_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Start$|^BonusStart(\d+)$"); if let Some(captures)=bonus_start_pattern.captures(other){ - return Ok(Self::Start(ModeID(captures[1].parse().map_err(IDParseError::ParseInt)?))); + return Ok(Self{ + zone:Zone::Start, + mode_id:ModeID(captures[1].parse().map_err(IDParseError::ParseInt)?), + }); } let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Finish$|^BonusFinish(\d+)$"); if let Some(captures)=bonus_finish_pattern.captures(other){ - return Ok(Self::Finish(ModeID(captures[1].parse().map_err(IDParseError::ParseInt)?))); + return Ok(Self{ + zone:Zone::Finish, + mode_id:ModeID(captures[1].parse().map_err(IDParseError::ParseInt)?), + }); } let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Anticheat$|^BonusAnticheat(\d+)$"); if let Some(captures)=bonus_finish_pattern.captures(other){ - return Ok(Self::Anticheat(ModeID(captures[1].parse().map_err(IDParseError::ParseInt)?))); + return Ok(Self{ + zone:Zone::Anticheat, + mode_id:ModeID(captures[1].parse().map_err(IDParseError::ParseInt)?), + }); } Err(IDParseError::NoCaptures) } } } } +impl std::fmt::Display for ModeElement{ + fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + match self{ + ModeElement{zone:Zone::Start,mode_id:ModeID::MAIN}=>write!(f,"MapStart"), + ModeElement{zone:Zone::Start,mode_id:ModeID::BONUS}=>write!(f,"BonusStart"), + ModeElement{zone:Zone::Start,mode_id:ModeID(mode_id)}=>write!(f,"Bonus{mode_id}Start"), + ModeElement{zone:Zone::Finish,mode_id:ModeID::MAIN}=>write!(f,"MapFinish"), + ModeElement{zone:Zone::Finish,mode_id:ModeID::BONUS}=>write!(f,"BonusFinish"), + ModeElement{zone:Zone::Finish,mode_id:ModeID(mode_id)}=>write!(f,"Bonus{mode_id}Finish"), + ModeElement{zone:Zone::Anticheat,mode_id:ModeID::MAIN}=>write!(f,"MapAnticheat"), + ModeElement{zone:Zone::Anticheat,mode_id:ModeID::BONUS}=>write!(f,"BonusAnticheat"), + ModeElement{zone:Zone::Anticheat,mode_id:ModeID(mode_id)}=>write!(f,"Bonus{mode_id}Anticheat"), + } + } +} #[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)] -struct SpawnID(u64); -impl SpawnID{ +struct StageID(u64); +impl StageID{ const FIRST:Self=Self(1); } -enum SpawnTeleport{ - Teleport(SpawnID), - Spawn(SpawnID), +enum StageElementBehaviour{ + Teleport, + Spawn, +} +struct StageElement{ + stage_id:StageID, + behaviour:StageElementBehaviour, } // Parse a SpawnTeleport from a part name -impl std::str::FromStr for SpawnTeleport{ +impl std::str::FromStr for StageElement{ type Err=IDParseError; fn from_str(s:&str)->Result{ // Trigger ForceTrigger Teleport ForceTeleport SpawnAt ForceSpawnAt let bonus_start_pattern=lazy_regex::lazy_regex!(r"^(?:Force)?(Teleport|SpawnAt|Trigger)(\d+)$"); if let Some(captures)=bonus_start_pattern.captures(s){ - return Ok(Self::Teleport(SpawnID(captures[1].parse().map_err(IDParseError::ParseInt)?))); + return Ok(StageElement{ + behaviour:StageElementBehaviour::Teleport, + stage_id:StageID(captures[1].parse().map_err(IDParseError::ParseInt)?), + }); } // Spawn let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^Spawn(\d+)$"); if let Some(captures)=bonus_finish_pattern.captures(s){ - return Ok(Self::Spawn(SpawnID(captures[1].parse().map_err(IDParseError::ParseInt)?))); + return Ok(StageElement{ + behaviour:StageElementBehaviour::Spawn, + stage_id:StageID(captures[1].parse().map_err(IDParseError::ParseInt)?), + }); } Err(IDParseError::NoCaptures) } } +impl std::fmt::Display for StageElement{ + fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + match self{ + StageElement{behaviour:StageElementBehaviour::Spawn,stage_id:StageID(stage_id)}=>write!(f,"Spawn{stage_id}"), + StageElement{behaviour:StageElementBehaviour::Teleport,stage_id:StageID(stage_id)}=>write!(f,"Teleport{stage_id}"), + } + } +} #[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)] struct WormholeID(u64); -enum Wormhole{ - In(WormholeID), - Out(WormholeID), +enum WormholeBehaviour{ + In, + Out, +} +struct WormholeElement{ + behaviour:WormholeBehaviour, + wormhole_id:WormholeID, } // Parse a Wormhole from a part name -impl std::str::FromStr for Wormhole{ +impl std::str::FromStr for WormholeElement{ type Err=IDParseError; fn from_str(s:&str)->Result{ let bonus_start_pattern=lazy_regex::lazy_regex!(r"^WormholeIn(\d+)$"); if let Some(captures)=bonus_start_pattern.captures(s){ - return Ok(Self::In(WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?))); + return Ok(Self{ + behaviour:WormholeBehaviour::In, + wormhole_id:WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?), + }); } let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^WormholeOut(\d+)$"); if let Some(captures)=bonus_finish_pattern.captures(s){ - return Ok(Self::Out(WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?))); + return Ok(Self{ + behaviour:WormholeBehaviour::Out, + wormhole_id:WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?), + }); } Err(IDParseError::NoCaptures) } } +impl std::fmt::Display for WormholeElement{ + fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + match self{ + WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id:WormholeID(wormhole_id)}=>write!(f,"WormholeIn{wormhole_id}"), + WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id:WormholeID(wormhole_id)}=>write!(f,"WormholeOut{wormhole_id}"), + } + } +} /// Count various map elements #[derive(Default)] @@ -141,8 +206,8 @@ struct Counts<'a>{ mode_start_counts:HashMap>, mode_finish_counts:HashMap>, mode_anticheat_counts:HashMap>, - teleport_counts:HashMap>, - spawn_counts:HashMap, + teleport_counts:HashMap>, + spawn_counts:HashMap, wormhole_in_counts:HashMap, wormhole_out_counts:HashMap, } @@ -164,21 +229,21 @@ pub fn get_model_info<'a>(dom:&'a rbx_dom_weak::WeakDom,model_instance:&'a rbx_d if class_is_a(instance.class.as_str(),"BasePart"){ // Zones match instance.name.parse(){ - Ok(Zone::Start(mode_id))=>counts.mode_start_counts.entry(mode_id).or_default().push(instance.name.as_str()), - Ok(Zone::Finish(mode_id))=>counts.mode_finish_counts.entry(mode_id).or_default().push(instance.name.as_str()), - Ok(Zone::Anticheat(mode_id))=>counts.mode_anticheat_counts.entry(mode_id).or_default().push(instance.name.as_str()), + Ok(ModeElement{zone:Zone::Start,mode_id})=>counts.mode_start_counts.entry(mode_id).or_default().push(instance.name.as_str()), + Ok(ModeElement{zone:Zone::Finish,mode_id})=>counts.mode_finish_counts.entry(mode_id).or_default().push(instance.name.as_str()), + Ok(ModeElement{zone:Zone::Anticheat,mode_id})=>counts.mode_anticheat_counts.entry(mode_id).or_default().push(instance.name.as_str()), Err(_)=>(), } // Spawns & Teleports match instance.name.parse(){ - Ok(SpawnTeleport::Teleport(spawn_id))=>counts.teleport_counts.entry(spawn_id).or_default().push(instance.name.as_str()), - Ok(SpawnTeleport::Spawn(spawn_id))=>*counts.spawn_counts.entry(spawn_id).or_insert(0)+=1, + Ok(StageElement{behaviour:StageElementBehaviour::Teleport,stage_id})=>counts.teleport_counts.entry(stage_id).or_default().push(instance.name.as_str()), + Ok(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id})=>*counts.spawn_counts.entry(stage_id).or_insert(0)+=1, Err(_)=>(), } // Wormholes match instance.name.parse(){ - Ok(Wormhole::In(wormhole_id))=>*counts.wormhole_in_counts.entry(wormhole_id).or_insert(0)+=1, - Ok(Wormhole::Out(wormhole_id))=>*counts.wormhole_out_counts.entry(wormhole_id).or_insert(0)+=1, + Ok(WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id})=>*counts.wormhole_in_counts.entry(wormhole_id).or_insert(0)+=1, + Ok(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id})=>*counts.wormhole_out_counts.entry(wormhole_id).or_insert(0)+=1, Err(_)=>(), } } @@ -345,9 +410,9 @@ struct MapCheck<'a>{ // Spawn1 must exist spawn1:Result, // Check for dangling Teleport# (no associated Spawn#) - teleport_counts:SetDifferenceCheck>>, + teleport_counts:SetDifferenceCheck>>, // No duplicate Spawn# - spawn_counts:DuplicateCheck, + spawn_counts:DuplicateCheck, // Check for dangling WormholeIn# (no associated WormholeOut#) wormhole_in_counts:SetDifferenceCheck>, // No duplicate WormholeOut# (duplicate WormholeIn# ok) @@ -391,7 +456,7 @@ impl<'a> ModelInfo<'a>{ }; // Spawn1 must exist - let spawn1=if self.counts.spawn_counts.contains_key(&SpawnID::FIRST){ + let spawn1=if self.counts.spawn_counts.contains_key(&StageID::FIRST){ Ok(Exists) }else{ Err(Absent) @@ -446,7 +511,7 @@ impl<'a> ModelInfo<'a>{ } impl MapCheck<'_>{ - fn result(self)->Result{ + fn result(self)->Result>{ match self{ MapCheck{ model_class:StringCheck(Ok(())), @@ -470,156 +535,269 @@ impl MapCheck<'_>{ game_id, }) }, - other=>Err(other.to_string()), + other=>Err(other.itemize()), } } } -fn write_comma_separated( - f:&mut std::fmt::Formatter<'_>, - mut it:impl Iterator, - custom_write:impl Fn(&mut std::fmt::Formatter<'_>,T)->std::fmt::Result -)->std::fmt::Result{ - if let Some(t)=it.next(){ - custom_write(f,t)?; - for t in it{ - write!(f,", ")?; - custom_write(f,t)?; - } +struct Separated{ + f:F, + separator:&'static str, +} +impl Separated{ + fn new(separator:&'static str,f:F)->Self{ + Self{separator,f} + } +} +impl std::fmt::Display for Separated + where + D:std::fmt::Display, + I:IntoIterator, + F:Fn()->I, +{ + fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + let mut it=(self.f)().into_iter(); + if let Some(first)=it.next(){ + write!(f,"{first}")?; + for item in it{ + write!(f,"{}{item}",self.separator)?; + } + } + Ok(()) } - Ok(()) } -/// Write a zone string such as BonusStart -macro_rules! write_zone{ - ($f:expr,$mode:expr,$zone:expr)=>{ - match $mode{ - ModeID(0)=>write!($f,concat!("Map",$zone)), - ModeID(1)=>write!($f,concat!("Bonus",$zone)), - ModeID(other)=>write!($f,concat!("Bonus{}",$zone),other), +struct Duplicates{ + display:D, + duplicates:usize, +} +impl Duplicates{ + fn new(display:D,duplicates:usize)->Self{ + Self{ + display, + duplicates, + } + } +} +impl std::fmt::Display for Duplicates{ + fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + write!(f,"{} ({} duplicates)",self.display,self.duplicates) + } +} + +#[derive(serde::Serialize)] +struct CheckSummary{ + name:&'static str, + summary:String, + passed:bool, + details:serde_json::Value, +} +impl CheckSummary{ + const fn passed(name:&'static str)->Self{ + Self{ + name, + summary:String::new(), + passed:true, + details:serde_json::Value::Null, + } + } +} +macro_rules! summary{ + ($name:literal,$summary:expr,$details:expr)=>{ + CheckSummary{ + name:$name, + summary:$summary, + passed:false, + details:serde_json::to_value($details)?, + } + }; +} +macro_rules! summary_format{ + ($name:literal,$fmt:literal,$details:expr)=>{ + CheckSummary{ + name:$name, + summary:format!($fmt), + passed:false, + details:serde_json::to_value($details)?, } }; } // Generate an error message for each observed issue separated by newlines. // This defines MapCheck.to_string() which is used in MapCheck.result() -impl std::fmt::Display for MapCheck<'_>{ - fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ - if let StringCheck(Err(context))=&self.model_class{ - writeln!(f,"Invalid model class: {context}")?; - } - if let StringCheck(Err(context))=&self.model_name{ - writeln!(f,"Model name must have snake_case: {context}")?; - } - match &self.display_name{ - Ok(Ok(StringCheck(Ok(_))))=>(), - Ok(Ok(StringCheck(Err(context))))=>writeln!(f,"DisplayName must have Title Case: {context}")?, - Ok(Err(context))=>writeln!(f,"Invalid DisplayName: {context}")?, - Err(StringValueError::ObjectNotFound)=>writeln!(f,"Missing DisplayName StringValue")?, - Err(StringValueError::ValueNotSet)=>writeln!(f,"DisplayName Value not set")?, - Err(StringValueError::NonStringValue)=>writeln!(f,"DisplayName Value is not a String")?, - } - match &self.creator{ - Ok(Ok(_))=>(), - Ok(Err(context))=>writeln!(f,"Invalid Creator: {context}")?, - Err(StringValueError::ObjectNotFound)=>writeln!(f,"Missing Creator StringValue")?, - Err(StringValueError::ValueNotSet)=>writeln!(f,"Creator Value not set")?, - Err(StringValueError::NonStringValue)=>writeln!(f,"Creator Value is not a String")?, - } - if let Err(_parse_game_id_error)=&self.game_id{ - writeln!(f,"Model name must be prefixed with bhop_ surf_ or flytrials_")?; - } - if let Err(Absent)=&self.mapstart{ - writeln!(f,"Model has no MapStart")?; - } - if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.mode_start_counts{ - write!(f,"Duplicate start zones: ")?; - write_comma_separated(f,context.iter(),|f,(mode_id,names)|{ - write_zone!(f,mode_id,"Start")?; - write!(f," ({} duplicates)",names.len())?; - Ok(()) - })?; - writeln!(f)?; - } - if let SetDifferenceCheck(Err(context))=&self.mode_finish_counts{ - if !context.extra.is_empty(){ - let plural=if context.extra.len()==1{"zone"}else{"zones"}; - write!(f,"No matching start zone for finish {plural}: ")?; - write_comma_separated(f,context.extra.iter(),|f,(mode_id,_names)| - write_zone!(f,mode_id,"Finish") - )?; - writeln!(f)?; +impl MapCheck<'_>{ + fn itemize(&self)->Result{ + let model_class=match &self.model_class{ + StringCheck(Ok(()))=>CheckSummary::passed("ModelClass"), + StringCheck(Err(context))=>summary_format!("ModelClass","Invalid model class: {context}",()), + }; + let model_name=match &self.model_name{ + StringCheck(Ok(()))=>CheckSummary::passed("ModelName"), + StringCheck(Err(context))=>summary_format!("ModelName","Model name must have snake_case: {context}",()), + }; + let display_name=match &self.display_name{ + Ok(Ok(StringCheck(Ok(_))))=>CheckSummary::passed("DisplayName"), + Ok(Ok(StringCheck(Err(context))))=>summary_format!("DisplayName","DisplayName must have Title Case: {context}",()), + Ok(Err(context))=>summary_format!("DisplayName","Invalid DisplayName: {context}",()), + Err(StringValueError::ObjectNotFound)=>summary!("DisplayName","Missing DisplayName StringValue".to_owned(),()), + Err(StringValueError::ValueNotSet)=>summary!("DisplayName","DisplayName Value not set".to_owned(),()), + Err(StringValueError::NonStringValue)=>summary!("DisplayName","DisplayName Value is not a String".to_owned(),()), + }; + let creator=match &self.creator{ + Ok(Ok(_))=>CheckSummary::passed("Creator"), + Ok(Err(context))=>summary_format!("Creator","Invalid Creator: {context}",()), + Err(StringValueError::ObjectNotFound)=>summary!("Creator","Missing Creator StringValue".to_owned(),()), + Err(StringValueError::ValueNotSet)=>summary!("Creator","Creator Value not set".to_owned(),()), + Err(StringValueError::NonStringValue)=>summary!("Creator","Creator Value is not a String".to_owned(),()), + }; + let game_id=match &self.game_id{ + Ok(_)=>CheckSummary::passed("GameID"), + Err(ParseGameIDError)=>summary!("GameID","Model name must be prefixed with bhop_ surf_ or flytrials_".to_owned(),()), + }; + let mapstart=match &self.mapstart{ + Ok(Exists)=>CheckSummary::passed("MapStart"), + Err(Absent)=>summary_format!("MapStart","Model has no MapStart",()), + }; + let duplicate_start=match &self.mode_start_counts{ + DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateStart"), + DuplicateCheck(Err(DuplicateCheckContext(context)))=>{ + let context=Separated::new(", ",||context.iter().map(|(&mode_id,names)| + Duplicates::new(ModeElement{zone:Zone::Start,mode_id},names.len()) + )); + summary_format!("DuplicateStart","Duplicate start zones: {context}",()) } - if !context.missing.is_empty(){ - let plural=if context.missing.len()==1{"zone"}else{"zones"}; - write!(f,"Missing finish {plural}: ")?; - write_comma_separated(f,context.missing.iter(),|f,mode_id| - write_zone!(f,mode_id,"Finish") - )?; - writeln!(f)?; + }; + let (extra_finish,missing_finish)=match &self.mode_finish_counts{ + SetDifferenceCheck(Ok(()))=>(CheckSummary::passed("ExtraFinish"),CheckSummary::passed("MissingFinish")), + SetDifferenceCheck(Err(context))=>( + if context.extra.is_empty(){ + CheckSummary::passed("ExtraFinish") + }else{ + let plural=if context.extra.len()==1{"zone"}else{"zones"}; + let context=Separated::new(", ",||context.extra.iter().map(|(&mode_id,_names)| + ModeElement{zone:Zone::Finish,mode_id} + )); + summary_format!("ExtraFinish","No matching start zone for finish {plural}: {context}",()) + }, + if context.missing.is_empty(){ + CheckSummary::passed("MissingFinish") + }else{ + let plural=if context.missing.len()==1{"zone"}else{"zones"}; + let context=Separated::new(", ",||context.missing.iter().map(|&mode_id| + ModeElement{zone:Zone::Finish,mode_id} + )); + summary_format!("MissingFinish","Missing finish {plural}: {context}",()) + } + ), + }; + let dangling_anticheat=match &self.mode_anticheat_counts{ + SetDifferenceCheck(Ok(()))=>CheckSummary::passed("DanglingAnticheat"), + SetDifferenceCheck(Err(context))=>{ + if context.extra.is_empty(){ + CheckSummary::passed("DanglingAnticheat") + }else{ + let plural=if context.extra.len()==1{"zone"}else{"zones"}; + let context=Separated::new(", ",||context.extra.iter().map(|(&mode_id,_names)| + ModeElement{zone:Zone::Anticheat,mode_id} + )); + summary_format!("DanglingAnticheat","No matching start zone for anticheat {plural}: {context}",()) + } } - } - if let SetDifferenceCheck(Err(context))=&self.mode_anticheat_counts{ - if !context.extra.is_empty(){ - let plural=if context.extra.len()==1{"zone"}else{"zones"}; - write!(f,"No matching start zone for anticheat {plural}: ")?; - write_comma_separated(f,context.extra.iter(),|f,(mode_id,_names)| - write_zone!(f,mode_id,"Anticheat") - )?; - writeln!(f)?; + }; + let spawn1=match &self.spawn1{ + Ok(Exists)=>CheckSummary::passed("Spawn1"), + Err(Absent)=>summary_format!("Spawn1","Model has no Spawn1",()), + }; + let dangling_teleport=match &self.teleport_counts{ + SetDifferenceCheck(Ok(()))=>CheckSummary::passed("DanglingTeleport"), + SetDifferenceCheck(Err(context))=>{ + let unique_names:HashSet<_>=context.extra.values().flat_map(|names|names.iter().copied()).collect(); + let plural=if unique_names.len()==1{"object"}else{"objects"}; + let context=Separated::new(", ",||&unique_names); + summary_format!("DanglingTeleport","No matching Spawn for {plural}: {context}",()) } - } - if let Err(Absent)=&self.spawn1{ - writeln!(f,"Model has no Spawn1")?; - } - if let SetDifferenceCheck(Err(context))=&self.teleport_counts{ - for names in context.extra.values(){ - let plural=if names.len()==1{"object"}else{"objects"}; - write!(f,"No matching Spawn for {plural}: ")?; - write_comma_separated(f,names.iter(),|f,&name|{ - write!(f,"{name}") - })?; - writeln!(f)?; + }; + let duplicate_spawns=match &self.spawn_counts{ + DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateSpawn"), + DuplicateCheck(Err(DuplicateCheckContext(context)))=>{ + let context=Separated::new(", ",||context.iter().map(|(&stage_id,&names)| + Duplicates::new(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id},names as usize) + )); + summary_format!("DuplicateSpawn","Duplicate Spawn: {context}",()) } - } - if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.spawn_counts{ - write!(f,"Duplicate Spawn: ")?; - write_comma_separated(f,context.iter(),|f,(SpawnID(spawn_id),count)| - write!(f,"Spawn{spawn_id} ({count} duplicates)") - )?; - writeln!(f)?; - } - if let SetDifferenceCheck(Err(context))=&self.wormhole_in_counts{ - if !context.extra.is_empty(){ - write!(f,"WormholeIn with no matching WormholeOut: ")?; - write_comma_separated(f,context.extra.iter(),|f,(WormholeID(wormhole_id),_count)| - write!(f,"WormholeIn{wormhole_id}") - )?; - writeln!(f)?; + }; + let (extra_wormhole_in,missing_wormhole_in)=match &self.wormhole_in_counts{ + SetDifferenceCheck(Ok(()))=>(CheckSummary::passed("ExtraWormholeIn"),CheckSummary::passed("MissingWormholeIn")), + SetDifferenceCheck(Err(context))=>( + if context.extra.is_empty(){ + CheckSummary::passed("ExtraWormholeIn") + }else{ + let context=Separated::new(", ",||context.extra.iter().map(|(&wormhole_id,_names)| + WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id} + )); + summary_format!("ExtraWormholeIn","WormholeIn with no matching WormholeOut: {context}",()) + }, + if context.missing.is_empty(){ + CheckSummary::passed("MissingWormholeIn") + }else{ + // This counts WormholeIn objects, but + // flipped logic is easier to understand + let context=Separated::new(", ",||context.missing.iter().map(|&wormhole_id| + WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id} + )); + summary_format!("MissingWormholeIn","WormholeOut with no matching WormholeIn: {context}",()) + } + ) + }; + let duplicate_wormhole_out=match &self.wormhole_out_counts{ + DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateWormholeOut"), + DuplicateCheck(Err(DuplicateCheckContext(context)))=>{ + let context=Separated::new(", ",||context.iter().map(|(&wormhole_id,&names)| + Duplicates::new(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id},names as usize) + )); + summary_format!("DuplicateWormholeOut","Duplicate WormholeOut: {context}",()) } - if !context.missing.is_empty(){ - // This counts WormholeIn objects, but - // flipped logic is easier to understand - write!(f,"WormholeOut with no matching WormholeIn: ")?; - write_comma_separated(f,context.missing.iter(),|f,WormholeID(wormhole_id)| - write!(f,"WormholeOut{wormhole_id}") - )?; - writeln!(f)?; - } - } - if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.wormhole_out_counts{ - write!(f,"Duplicate WormholeOut: ")?; - write_comma_separated(f,context.iter(),|f,(WormholeID(wormhole_id),count)| - write!(f,"WormholeOut{wormhole_id} ({count} duplicates)") - )?; - writeln!(f)?; - } - Ok(()) + }; + Ok(MapCheckList{checks:Box::new([ + model_class, + model_name, + display_name, + creator, + game_id, + mapstart, + duplicate_start, + extra_finish, + missing_finish, + dangling_anticheat, + spawn1, + dangling_teleport, + duplicate_spawns, + extra_wormhole_in, + missing_wormhole_in, + duplicate_wormhole_out, + ])}) } } +#[derive(serde::Serialize)] +struct MapCheckList{ + checks:Box<[CheckSummary;16]>, +} +impl MapCheckList{ + fn summary(&self)->String{ + Separated::new("; ",||self.checks.iter().filter_map(|check| + (!check.passed).then_some(check.summary.as_str()) + )).to_string() + } +} + +pub struct Summary{ + pub summary:String, + pub json:serde_json::Value, +} + pub struct CheckReportAndVersion{ - pub status:Result, + pub status:Result, pub version:u64, } @@ -656,7 +834,14 @@ impl crate::message_handler::MessageHandler{ let map_check=model_info.check(); // check the report, generate an error message if it fails the check - let status=map_check.result(); + let status=match map_check.result(){ + Ok(map_info)=>Ok(map_info), + Err(Ok(summary))=>Err(Summary{ + summary:summary.summary(), + json:serde_json::to_value(&summary).map_err(Error::ToJsonValue)?, + }), + Err(Err(e))=>return Err(Error::ToJsonValue(e)), + }; Ok(CheckReportAndVersion{status,version}) } diff --git a/validation/src/check_mapfix.rs b/validation/src/check_mapfix.rs index de95811..d8ed2fd 100644 --- a/validation/src/check_mapfix.rs +++ b/validation/src/check_mapfix.rs @@ -34,17 +34,21 @@ impl crate::message_handler::MessageHandler{ Ok(CheckReportAndVersion{status:Err(report),..})=>self.api.action_mapfix_request_changes( submissions_api::types::ActionMapfixRequestChangesRequest{ MapfixID:mapfix_id, - ErrorMessage:report, + ErrorMessage:report.summary, } ).await.map_err(Error::ApiActionMapfixCheck)?, - // TODO: report the error // update the mapfix model status to request changes - Err(e)=>self.api.action_mapfix_request_changes( - submissions_api::types::ActionMapfixRequestChangesRequest{ - MapfixID:mapfix_id, - ErrorMessage:e.to_string(), - } - ).await.map_err(Error::ApiActionMapfixCheck)?, + Err(e)=>{ + // log error + println!("[check_mapfix] Error: {e}"); + + self.api.action_mapfix_request_changes( + submissions_api::types::ActionMapfixRequestChangesRequest{ + MapfixID:mapfix_id, + ErrorMessage:e.to_string(), + } + ).await.map_err(Error::ApiActionMapfixCheck)?; + }, } Ok(()) diff --git a/validation/src/check_submission.rs b/validation/src/check_submission.rs index 4140539..7e6463a 100644 --- a/validation/src/check_submission.rs +++ b/validation/src/check_submission.rs @@ -35,17 +35,21 @@ impl crate::message_handler::MessageHandler{ Ok(CheckReportAndVersion{status:Err(report),..})=>self.api.action_submission_request_changes( submissions_api::types::ActionSubmissionRequestChangesRequest{ SubmissionID:submission_id, - ErrorMessage:report, + ErrorMessage:report.summary, } ).await.map_err(Error::ApiActionSubmissionCheck)?, - // TODO: report the error // update the submission model status to request changes - Err(e)=>self.api.action_submission_request_changes( - submissions_api::types::ActionSubmissionRequestChangesRequest{ - SubmissionID:submission_id, - ErrorMessage:e.to_string(), - } - ).await.map_err(Error::ApiActionSubmissionCheck)?, + Err(e)=>{ + // log error + println!("[check_submission] Error: {e}"); + + self.api.action_submission_request_changes( + submissions_api::types::ActionSubmissionRequestChangesRequest{ + SubmissionID:submission_id, + ErrorMessage:e.to_string(), + } + ).await.map_err(Error::ApiActionSubmissionCheck)?; + }, } Ok(()) diff --git a/validation/src/create_mapfix.rs b/validation/src/create_mapfix.rs index 9980c53..0884efc 100644 --- a/validation/src/create_mapfix.rs +++ b/validation/src/create_mapfix.rs @@ -43,6 +43,9 @@ impl crate::message_handler::MessageHandler{ let create_result=self.create_mapfix_inner(create_info).await; if let Err(e)=create_result{ + // log error + println!("[create_mapfix] Error: {e}"); + self.api.action_operation_failed(submissions_api::types::ActionOperationFailedRequest{ OperationID:operation_id, StatusMessage:e.to_string(), diff --git a/validation/src/create_submission.rs b/validation/src/create_submission.rs index 3cfc3cc..56b3bd0 100644 --- a/validation/src/create_submission.rs +++ b/validation/src/create_submission.rs @@ -57,6 +57,9 @@ impl crate::message_handler::MessageHandler{ let create_result=self.create_submission_inner(create_info).await; if let Err(e)=create_result{ + // log error + println!("[create_submission] Error: {e}"); + self.api.action_operation_failed(submissions_api::types::ActionOperationFailedRequest{ OperationID:operation_id, StatusMessage:e.to_string(), diff --git a/validation/src/validate_mapfix.rs b/validation/src/validate_mapfix.rs index 56f29bf..e08197d 100644 --- a/validation/src/validate_mapfix.rs +++ b/validation/src/validate_mapfix.rs @@ -26,6 +26,9 @@ impl crate::message_handler::MessageHandler{ ).await.map_err(Error::ApiActionMapfixValidate)?; }, Err(e)=>{ + // log error + println!("[validate_mapfix] Error: {e}"); + // update the mapfix model status to accepted self.api.action_mapfix_accepted(submissions_api::types::ActionMapfixAcceptedRequest{ MapfixID:mapfix_id, diff --git a/validation/src/validate_submission.rs b/validation/src/validate_submission.rs index c60a88b..5bf97b3 100644 --- a/validation/src/validate_submission.rs +++ b/validation/src/validate_submission.rs @@ -26,6 +26,9 @@ impl crate::message_handler::MessageHandler{ ).await.map_err(Error::ApiActionSubmissionValidate)?; }, Err(e)=>{ + // log error + println!("[validate_submission] Error: {e}"); + // update the submission model status to accepted self.api.action_submission_accepted(submissions_api::types::ActionSubmissionAcceptedRequest{ SubmissionID:submission_id, diff --git a/web/src/app/admin-submit/page.tsx b/web/src/app/admin-submit/page.tsx index 52adc56..b04a0b9 100644 --- a/web/src/app/admin-submit/page.tsx +++ b/web/src/app/admin-submit/page.tsx @@ -32,7 +32,7 @@ export default function SubmissionInfoPage() { 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"), + AssetID: Number((formData.get("asset-id") as string) ?? "-1"), }; console.log(payload) diff --git a/web/src/app/favicon.ico b/web/src/app/favicon.ico new file mode 100644 index 0000000..6f72cd3 Binary files /dev/null and b/web/src/app/favicon.ico differ diff --git a/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx b/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx index b695f45..70b93fe 100644 --- a/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx +++ b/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx @@ -10,6 +10,7 @@ interface ReviewAction { const ReviewActions = { Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction, + BypassSubmit: {name:"Bypass Submit",action:"bypass-submit"} as ReviewAction, ResetSubmitting: {name:"Reset Submitting (fix softlocked status)",action:"reset-submitting"} as ReviewAction, Revoke: {name:"Revoke",action:"revoke"} as ReviewAction, Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction, @@ -119,6 +120,10 @@ export default function ReviewButtons(props: ReviewId) { } if (roles&RolesConstants.MapfixReview) { + // you can force submit a map in ChangesRequested status + if (!is_submitter && mapfixStatus === MapfixStatus.ChangesRequested) { + visibleButtons.push({ action: ReviewActions.BypassSubmit, color: "error", mapfixId }); + } // you can't review your own mapfix! // note that this means there needs to be more than one person with MapfixReview if (!is_submitter && mapfixStatus === MapfixStatus.Submitted) { diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx index 98b31e2..e4674b6 100644 --- a/web/src/app/mapfixes/page.tsx +++ b/web/src/app/mapfixes/page.tsx @@ -11,29 +11,10 @@ import "./(styles)/page.scss"; import { ListSortConstants } from "../ts/Sort"; export default function MapfixInfoPage() { - const [mapfixes, setMapfixes] = useState({Total:0,Mapfixes:[]}) + const [mapfixes, setMapfixes] = useState(null) const [currentPage, setCurrentPage] = useState(1); const cardsPerPage = 24; // built to fit on a 1920x1080 monitor - const totalPages = Math.ceil(mapfixes.Total / cardsPerPage); - - const currentCards = mapfixes.Mapfixes.slice( - (currentPage - 1) * cardsPerPage, - currentPage * cardsPerPage - ); - - const nextPage = () => { - if (currentPage < totalPages) { - setCurrentPage(currentPage + 1); - } - }; - - const prevPage = () => { - if (currentPage > 1) { - setCurrentPage(currentPage - 1); - } - }; - useEffect(() => { async function fetchMapfixes() { const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`) @@ -55,7 +36,26 @@ export default function MapfixInfoPage() { } - if (mapfixes && mapfixes.Total == 0) { + const totalPages = Math.ceil(mapfixes.Total / cardsPerPage); + + const currentCards = mapfixes.Mapfixes.slice( + (currentPage - 1) * cardsPerPage, + currentPage * cardsPerPage + ); + + const nextPage = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + const prevPage = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + + if (mapfixes.Total == 0) { return
Mapfixes list is empty. diff --git a/web/src/app/maps/[mapId]/fix/page.tsx b/web/src/app/maps/[mapId]/fix/page.tsx index 2ae2def..e5e7389 100644 --- a/web/src/app/maps/[mapId]/fix/page.tsx +++ b/web/src/app/maps/[mapId]/fix/page.tsx @@ -27,7 +27,7 @@ export default function MapfixInfoPage() { const formData = new FormData(form); const payload: MapfixPayload = { - AssetID: Number((formData.get("asset-id") as string) ?? "0"), + AssetID: Number((formData.get("asset-id") as string) ?? "-1"), TargetAssetID: Number(dynamicId.mapId), Description: (formData.get("description") as string) ?? "unknown", // TEMPORARY! TODO: Change }; diff --git a/web/src/app/submissions/[submissionId]/_reviewButtons.tsx b/web/src/app/submissions/[submissionId]/_reviewButtons.tsx index a5eac74..b388cf1 100644 --- a/web/src/app/submissions/[submissionId]/_reviewButtons.tsx +++ b/web/src/app/submissions/[submissionId]/_reviewButtons.tsx @@ -10,7 +10,8 @@ interface ReviewAction { const ReviewActions = { Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction, - ForceSubmit: {name:"Force Submit",action:"trigger-submit"} as ReviewAction, + AdminSubmit: {name:"Admin Submit",action:"trigger-submit"} as ReviewAction, + BypassSubmit: {name:"Bypass Submit",action:"bypass-submit"} as ReviewAction, ResetSubmitting: {name:"Reset Submitting (fix softlocked status)",action:"reset-submitting"} as ReviewAction, Revoke: {name:"Revoke",action:"revoke"} as ReviewAction, Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction, @@ -122,7 +123,8 @@ 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.ForceSubmit, color: "error", submissionId }); + visibleButtons.push({ action: ReviewActions.AdminSubmit, color: "error", submissionId }); + visibleButtons.push({ action: ReviewActions.BypassSubmit, color: "error", submissionId }); } // you can't review your own submission! // note that this means there needs to be more than one person with SubmissionReview diff --git a/web/src/app/submissions/page.tsx b/web/src/app/submissions/page.tsx index 8a7c017..bd67b33 100644 --- a/web/src/app/submissions/page.tsx +++ b/web/src/app/submissions/page.tsx @@ -9,29 +9,10 @@ import "./(styles)/page.scss"; import { ListSortConstants } from "../ts/Sort"; export default function SubmissionInfoPage() { - const [submissions, setSubmissions] = useState({Total:0,Submissions:[]}) + const [submissions, setSubmissions] = useState(null) const [currentPage, setCurrentPage] = useState(1); const cardsPerPage = 24; // built to fit on a 1920x1080 monitor - const totalPages = Math.ceil(submissions.Total / cardsPerPage); - - const currentCards = submissions.Submissions.slice( - (currentPage - 1) * cardsPerPage, - currentPage * cardsPerPage - ); - - const nextPage = () => { - if (currentPage < totalPages) { - setCurrentPage(currentPage + 1); - } - }; - - const prevPage = () => { - if (currentPage > 1) { - setCurrentPage(currentPage - 1); - } - }; - useEffect(() => { async function fetchSubmissions() { const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`) @@ -53,7 +34,26 @@ export default function SubmissionInfoPage() { } - if (submissions && submissions.Total == 0) { + const totalPages = Math.ceil(submissions.Total / cardsPerPage); + + const currentCards = submissions.Submissions.slice( + (currentPage - 1) * cardsPerPage, + currentPage * cardsPerPage + ); + + const nextPage = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + const prevPage = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + + if (submissions.Total == 0) { return
Submissions list is empty. diff --git a/web/src/app/submit/page.tsx b/web/src/app/submit/page.tsx index 7e89ba8..99afdae 100644 --- a/web/src/app/submit/page.tsx +++ b/web/src/app/submit/page.tsx @@ -32,7 +32,7 @@ export default function SubmissionInfoPage() { 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"), + AssetID: Number((formData.get("asset-id") as string) ?? "-1"), }; console.log(payload) diff --git a/web/src/app/thumbnails/asset/[assetId]/route.tsx b/web/src/app/thumbnails/asset/[assetId]/route.tsx index b7992f4..e7232d8 100644 --- a/web/src/app/thumbnails/asset/[assetId]/route.tsx +++ b/web/src/app/thumbnails/asset/[assetId]/route.tsx @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; +const cache = new Map(); +const CACHE_TTL = 15 * 60 * 1000; + export async function GET( request: NextRequest, context: { params: Promise<{ assetId: number }> } @@ -27,6 +30,19 @@ export async function GET( } } catch { } + const now = Date.now(); + const cached = cache.get(finalAssetId); + + if (cached && cached.expires > now) { + return new NextResponse(cached.buffer, { + headers: { + 'Content-Type': 'image/png', + 'Content-Length': cached.buffer.length.toString(), + 'Cache-Control': `public, max-age=${CACHE_TTL / 1000}`, + }, + }); + } + try { const response = await fetch( `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${finalAssetId}` @@ -54,10 +70,13 @@ export async function GET( const arrayBuffer = await imageResponse.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); + cache.set(finalAssetId, { buffer, expires: now + CACHE_TTL }); + return new NextResponse(buffer, { headers: { 'Content-Type': 'image/png', 'Content-Length': buffer.length.toString(), + 'Cache-Control': `public, max-age=${CACHE_TTL / 1000}`, }, }); } catch { diff --git a/web/src/app/thumbnails/maps/[mapId]/route.tsx b/web/src/app/thumbnails/maps/[mapId]/route.tsx index 174feee..a923d21 100644 --- a/web/src/app/thumbnails/maps/[mapId]/route.tsx +++ b/web/src/app/thumbnails/maps/[mapId]/route.tsx @@ -5,9 +5,12 @@ export async function GET( context: { params: Promise<{ mapId: string }> } ): Promise { // TODO: implement this, we need a cdn for in-game map thumbnails... - + const { mapId } = await context.params; - const baseUrl = request.nextUrl.origin; // Gets the current base URL - return NextResponse.redirect(`${baseUrl}/thumbnails/asset/${mapId}`); + const protocol = request.headers.get("x-forwarded-proto") || "https"; + const host = request.headers.get("host"); + const origin = `${protocol}://${host}`; + + return NextResponse.redirect(`${origin}/thumbnails/asset/${mapId}`); } \ No newline at end of file diff --git a/web/src/middleware.ts b/web/src/middleware.ts index 0a25a76..b6220b5 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -1,29 +1,33 @@ import { NextRequest, NextResponse } from "next/server" export const config = { - matcher: ["/api/:path*", "/auth/:path*"], + matcher: ["/api/:path*", "/auth/:path*"], } export function middleware(request: NextRequest) { - const { pathname, search } = request.nextUrl + const { pathname, search } = request.nextUrl - if (pathname.startsWith("/api")) { - if (!process.env.API_HOST) { - throw new Error('env variable "API_HOST" is not set') - } - const apiUrl = new URL(process.env.API_HOST + pathname.replace(/^\/api/, '') + search) - return NextResponse.rewrite(apiUrl, { request }) - } else if (pathname.startsWith("/auth")) { + if (pathname.startsWith("/api")) { + if (!process.env.API_HOST) { + throw new Error('env variable "API_HOST" is not set') + } + + const baseUrl = process.env.API_HOST.replace(/\/$/, ""); + const path = pathname.replace(/^\/api/, ""); + const apiUrl = new URL(baseUrl + path + search); + + return NextResponse.rewrite(apiUrl, { request }); + } else if (pathname.startsWith("/auth")) { if (!process.env.AUTH_HOST) { throw new Error('env variable "AUTH_HOST" is not set') } - - const authHost = process.env.AUTH_HOST.replace(/\/$/, "") - const path = pathname.replace(/^\/auth/, "") - const redirectUrl = new URL(authHost + path + search) - - return NextResponse.redirect(redirectUrl, 302) - } - return NextResponse.next() -} \ No newline at end of file + const authHost = process.env.AUTH_HOST.replace(/\/$/, ""); + const path = pathname.replace(/^\/auth/, ""); + const redirectUrl = new URL(authHost + path + search); + + return NextResponse.redirect(redirectUrl, 302); + } + + return NextResponse.next() +}