From abd233ce65a6d6f4d4ecec0df77fdddae0f8ae4d Mon Sep 17 00:00:00 2001 From: Quaternions Date: Fri, 13 Jun 2025 03:58:01 +0000 Subject: [PATCH 1/7] Validation: Make Assets Loadable on Maptest (#198) Closes #43. This is a very bare bones implementation, but gets us started on https://git.itzana.me/StrafesNET/maps-service/milestone/3 This will break production as written! A proper implementation requires a separate api key since the maptest places are to be hosted on a different group. Edit: It will actually not break, because it is using cookie access. The staging cookie has permission to edit StrafesNET Maptest asset permissions via StrafesNET_CI3, while prod also has access via StrafesNET_CI2. Both staging and prod versions of the website will add maptest asset access to the same places on StrafesNET Maptest. Reviewed-on: https://git.itzana.me/StrafesNET/maps-service/pulls/198 Co-authored-by: Quaternions Co-committed-by: Quaternions --- Cargo.lock | 4 ++-- validation/Cargo.toml | 2 +- validation/src/create.rs | 17 ++++++++++++++++- validation/src/validator.rs | 9 +++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4de520c..49fc146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1379,9 +1379,9 @@ dependencies = [ [[package]] name = "rbx_asset" -version = "0.4.5" +version = "0.4.6" source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/" -checksum = "b448bf22f70748215c2a937158f83790bf3f4df81e2af8521a089bc821155360" +checksum = "860909d8375a54deb2a50187b1b792dcf88c0d2e21c18f0c1d44b34e2f027f36" dependencies = [ "bytes", "chrono", diff --git a/validation/Cargo.toml b/validation/Cargo.toml index 06c3a83..3bc4c13 100644 --- a/validation/Cargo.toml +++ b/validation/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" submissions-api = { path = "api", features = ["internal"], default-features = false, registry = "strafesnet" } async-nats = "0.41.0" futures = "0.3.31" -rbx_asset = { version = "0.4.5", registry = "strafesnet" } +rbx_asset = { version = "0.4.6", registry = "strafesnet" } rbx_binary = "1.0.0" rbx_dom_weak = "3.0.0" rbx_reflection_database = "1.0.3" diff --git a/validation/src/create.rs b/validation/src/create.rs index f29d4ca..3a14afe 100644 --- a/validation/src/create.rs +++ b/validation/src/create.rs @@ -9,6 +9,8 @@ pub enum Error{ Download(crate::download::Error), ModelFileDecode(ReadDomError), GetRootInstance(GetRootInstanceError), + InvalidGamePrefix, + LoadableOnMaptest(rbx_asset::cookie::SetAssetsPermissionsError), } impl std::fmt::Display for Error{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ @@ -62,11 +64,24 @@ impl crate::message_handler::MessageHandler{ game_id, }=get_mapinfo(&dom,model_instance); + let game_id=game_id.map_err(|_|Error::InvalidGamePrefix)?; + + let universe_id=match &game_id{ + GameID::Bhop=>4422715291, + GameID::Surf=>4422716026, + GameID::FlyTrials=>4419912257, + }; + let config=rbx_asset::cookie::SetAssetsPermissionsRequest{ + universe_id, + asset_ids:&[create_info.ModelID], + }; + self.cookie_context.set_assets_permissions(config).await.map_err(Error::LoadableOnMaptest)?; + Ok(CreateResult{ AssetOwner:user_id, DisplayName:display_name.ok().map(ToOwned::to_owned), Creator:creator.ok().map(ToOwned::to_owned), - GameID:game_id.ok(), + GameID:Some(game_id), AssetVersion:asset_version, }) } diff --git a/validation/src/validator.rs b/validation/src/validator.rs index 39ffa0c..0945528 100644 --- a/validation/src/validator.rs +++ b/validation/src/validator.rs @@ -51,6 +51,7 @@ pub enum Error{ ApiGetScriptFromHash(submissions_api::types::ScriptSingleItemError), ApiUpdateMapfixModel(submissions_api::Error), ApiUpdateSubmissionModel(submissions_api::Error), + LoadableOnMaptest(rbx_asset::cookie::SetAssetsPermissionsError), ModelFileRootMustHaveOneChild, ModelFileChildRefIsNil, ModelFileEncode(rbx_binary::EncodeError), @@ -296,6 +297,14 @@ impl crate::message_handler::MessageHandler{ }, } + // Map Staging + let universe_id=7895115682; + let config=rbx_asset::cookie::SetAssetsPermissionsRequest{ + universe_id, + asset_ids:&[validated_model_id], + }; + self.cookie_context.set_assets_permissions(config).await.map_err(Error::LoadableOnMaptest)?; + Ok(()) } } -- 2.49.1 From ed7109270fd00eccdf940b5c1c258c5941648060 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Sat, 14 Jun 2025 02:33:19 +0000 Subject: [PATCH 2/7] Audit Event CheckList (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depends on #160, #196, #197. Closes #147. This introduces a new type of audit event: the CheckList. This is a list of map checks that the validator performed. The intention is to update the web interface to display ✅ check marks for every check passed and ❌ for every check failed, and also include the summary of why the check failed. ~~The `Details` field would be the complete internal structure of the check in json, but I'm thinking it's unnecessary and should just be omitted.~~ The `Details` field has been removed. ```go type Check struct { Name string `json:"name"` Summary string `json:"summary"` Passed bool `json:"passed"` } type AuditEventDataCheckList struct { CheckList []Check `json:"check_list"` } ``` This is created instead of the Error audit event when the validator requests changes, but the Error audit event can still be created for other purposes. - [x] Make a proper error instead of hijacking a CheckList Reviewed-on: https://git.itzana.me/StrafesNET/maps-service/pulls/181 Reviewed-by: itzaname Co-authored-by: Quaternions Co-committed-by: Quaternions --- openapi-internal.yaml | 65 +++++ pkg/internal/oas_client_gen.go | 200 +++++++++++++ pkg/internal/oas_handlers_gen.go | 328 ++++++++++++++++++++++ pkg/internal/oas_json_gen.go | 180 ++++++++++++ pkg/internal/oas_operations_gen.go | 2 + pkg/internal/oas_parameters_gen.go | 166 +++++++++++ pkg/internal/oas_request_decoders_gen.go | 142 ++++++++++ pkg/internal/oas_request_encoders_gen.go | 28 ++ pkg/internal/oas_response_decoders_gen.go | 120 ++++++++ pkg/internal/oas_response_encoders_gen.go | 14 + pkg/internal/oas_router_gen.go | 92 ++++++ pkg/internal/oas_schemas_gen.go | 45 +++ pkg/internal/oas_server_gen.go | 12 + pkg/internal/oas_unimplemented_gen.go | 18 ++ pkg/internal/oas_validators_gen.go | 77 +++++ pkg/model/audit_event.go | 13 + pkg/service_internal/audit_events.go | 21 ++ pkg/service_internal/mapfixes.go | 30 ++ pkg/service_internal/submissions.go | 30 ++ validation/api/src/internal.rs | 24 ++ validation/api/src/types.rs | 22 ++ validation/src/check.rs | 157 +++++------ validation/src/check_mapfix.rs | 10 +- validation/src/check_submission.rs | 10 +- web/src/app/ts/AuditEvent.ts | 20 +- 25 files changed, 1725 insertions(+), 101 deletions(-) diff --git a/openapi-internal.yaml b/openapi-internal.yaml index 8166333..a6dcaa0 100644 --- a/openapi-internal.yaml +++ b/openapi-internal.yaml @@ -89,6 +89,29 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /mapfixes/{MapfixID}/checklist: + post: + summary: Validator posts a checklist to the audit log + operationId: createMapfixAuditCheckList + tags: + - Mapfixes + parameters: + - $ref: '#/components/parameters/MapfixID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CheckList' + responses: + "204": + description: Successful response + default: + description: General Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /mapfixes/{MapfixID}/status/validator-submitted: post: summary: (Internal endpoint) Role Validator changes status from Submitting -> Submitted @@ -304,6 +327,29 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /submissions/{SubmissionID}/checklist: + post: + summary: Validator posts a checklist to the audit log + operationId: createSubmissionAuditCheckList + tags: + - Submissions + parameters: + - $ref: '#/components/parameters/SubmissionID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CheckList' + responses: + "204": + description: Successful response + default: + description: General Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /submissions/{SubmissionID}/status/validator-submitted: post: summary: (Internal endpoint) Role Validator changes status from Submitting -> Submitted @@ -867,6 +913,25 @@ components: type: integer format: int32 minimum: 0 + Check: + required: + - Name + - Summary + - Passed + type: object + properties: + Name: + type: string + maxLength: 128 + Summary: + type: string + maxLength: 4096 + Passed: + type: boolean + CheckList: + type: array + items: + $ref: "#/components/schemas/Check" Error: description: Represents error object type: object diff --git a/pkg/internal/oas_client_gen.go b/pkg/internal/oas_client_gen.go index 927f983..ba9a38c 100644 --- a/pkg/internal/oas_client_gen.go +++ b/pkg/internal/oas_client_gen.go @@ -100,6 +100,12 @@ type Invoker interface { // // POST /mapfixes CreateMapfix(ctx context.Context, request *MapfixCreate) (*MapfixID, error) + // CreateMapfixAuditCheckList invokes createMapfixAuditCheckList operation. + // + // Validator posts a checklist to the audit log. + // + // POST /mapfixes/{MapfixID}/checklist + CreateMapfixAuditCheckList(ctx context.Context, request CheckList, params CreateMapfixAuditCheckListParams) error // CreateMapfixAuditError invokes createMapfixAuditError operation. // // Validator posts an error to the audit log. @@ -124,6 +130,12 @@ type Invoker interface { // // POST /submissions CreateSubmission(ctx context.Context, request *SubmissionCreate) (*SubmissionID, error) + // CreateSubmissionAuditCheckList invokes createSubmissionAuditCheckList operation. + // + // Validator posts a checklist to the audit log. + // + // POST /submissions/{SubmissionID}/checklist + CreateSubmissionAuditCheckList(ctx context.Context, request CheckList, params CreateSubmissionAuditCheckListParams) error // CreateSubmissionAuditError invokes createSubmissionAuditError operation. // // Validator posts an error to the audit log. @@ -1441,6 +1453,100 @@ func (c *Client) sendCreateMapfix(ctx context.Context, request *MapfixCreate) (r return result, nil } +// CreateMapfixAuditCheckList invokes createMapfixAuditCheckList operation. +// +// Validator posts a checklist to the audit log. +// +// POST /mapfixes/{MapfixID}/checklist +func (c *Client) CreateMapfixAuditCheckList(ctx context.Context, request CheckList, params CreateMapfixAuditCheckListParams) error { + _, err := c.sendCreateMapfixAuditCheckList(ctx, request, params) + return err +} + +func (c *Client) sendCreateMapfixAuditCheckList(ctx context.Context, request CheckList, params CreateMapfixAuditCheckListParams) (res *CreateMapfixAuditCheckListNoContent, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("createMapfixAuditCheckList"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/mapfixes/{MapfixID}/checklist"), + } + + // 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, CreateMapfixAuditCheckListOperation, + 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] = "/checklist" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + if err := encodeCreateMapfixAuditCheckListRequest(request, r); err != nil { + return res, errors.Wrap(err, "encode request") + } + + 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 := decodeCreateMapfixAuditCheckListResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + // CreateMapfixAuditError invokes createMapfixAuditError operation. // // Validator posts an error to the audit log. @@ -1775,6 +1881,100 @@ func (c *Client) sendCreateSubmission(ctx context.Context, request *SubmissionCr return result, nil } +// CreateSubmissionAuditCheckList invokes createSubmissionAuditCheckList operation. +// +// Validator posts a checklist to the audit log. +// +// POST /submissions/{SubmissionID}/checklist +func (c *Client) CreateSubmissionAuditCheckList(ctx context.Context, request CheckList, params CreateSubmissionAuditCheckListParams) error { + _, err := c.sendCreateSubmissionAuditCheckList(ctx, request, params) + return err +} + +func (c *Client) sendCreateSubmissionAuditCheckList(ctx context.Context, request CheckList, params CreateSubmissionAuditCheckListParams) (res *CreateSubmissionAuditCheckListNoContent, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("createSubmissionAuditCheckList"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/submissions/{SubmissionID}/checklist"), + } + + // 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, CreateSubmissionAuditCheckListOperation, + 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] = "/checklist" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + if err := encodeCreateSubmissionAuditCheckListRequest(request, r); err != nil { + return res, errors.Wrap(err, "encode request") + } + + 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 := decodeCreateSubmissionAuditCheckListResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + // CreateSubmissionAuditError invokes createSubmissionAuditError operation. // // Validator posts an error to the audit log. diff --git a/pkg/internal/oas_handlers_gen.go b/pkg/internal/oas_handlers_gen.go index a7c67c7..fc18a64 100644 --- a/pkg/internal/oas_handlers_gen.go +++ b/pkg/internal/oas_handlers_gen.go @@ -1858,6 +1858,170 @@ func (s *Server) handleCreateMapfixRequest(args [0]string, argsEscaped bool, w h } } +// handleCreateMapfixAuditCheckListRequest handles createMapfixAuditCheckList operation. +// +// Validator posts a checklist to the audit log. +// +// POST /mapfixes/{MapfixID}/checklist +func (s *Server) handleCreateMapfixAuditCheckListRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("createMapfixAuditCheckList"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/mapfixes/{MapfixID}/checklist"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), CreateMapfixAuditCheckListOperation, + 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: CreateMapfixAuditCheckListOperation, + ID: "createMapfixAuditCheckList", + } + ) + params, err := decodeCreateMapfixAuditCheckListParams(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 + } + request, close, err := s.decodeCreateMapfixAuditCheckListRequest(r) + if err != nil { + err = &ogenerrors.DecodeRequestError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeRequest", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + defer func() { + if err := close(); err != nil { + recordError("CloseRequest", err) + } + }() + + var response *CreateMapfixAuditCheckListNoContent + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: CreateMapfixAuditCheckListOperation, + OperationSummary: "Validator posts a checklist to the audit log", + OperationID: "createMapfixAuditCheckList", + Body: request, + Params: middleware.Parameters{ + { + Name: "MapfixID", + In: "path", + }: params.MapfixID, + }, + Raw: r, + } + + type ( + Request = CheckList + Params = CreateMapfixAuditCheckListParams + Response = *CreateMapfixAuditCheckListNoContent + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackCreateMapfixAuditCheckListParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + err = s.h.CreateMapfixAuditCheckList(ctx, request, params) + return response, err + }, + ) + } else { + err = s.h.CreateMapfixAuditCheckList(ctx, request, 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 := encodeCreateMapfixAuditCheckListResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + // handleCreateMapfixAuditErrorRequest handles createMapfixAuditError operation. // // Validator posts an error to the audit log. @@ -2458,6 +2622,170 @@ func (s *Server) handleCreateSubmissionRequest(args [0]string, argsEscaped bool, } } +// handleCreateSubmissionAuditCheckListRequest handles createSubmissionAuditCheckList operation. +// +// Validator posts a checklist to the audit log. +// +// POST /submissions/{SubmissionID}/checklist +func (s *Server) handleCreateSubmissionAuditCheckListRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("createSubmissionAuditCheckList"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/submissions/{SubmissionID}/checklist"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), CreateSubmissionAuditCheckListOperation, + 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: CreateSubmissionAuditCheckListOperation, + ID: "createSubmissionAuditCheckList", + } + ) + params, err := decodeCreateSubmissionAuditCheckListParams(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 + } + request, close, err := s.decodeCreateSubmissionAuditCheckListRequest(r) + if err != nil { + err = &ogenerrors.DecodeRequestError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeRequest", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + defer func() { + if err := close(); err != nil { + recordError("CloseRequest", err) + } + }() + + var response *CreateSubmissionAuditCheckListNoContent + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: CreateSubmissionAuditCheckListOperation, + OperationSummary: "Validator posts a checklist to the audit log", + OperationID: "createSubmissionAuditCheckList", + Body: request, + Params: middleware.Parameters{ + { + Name: "SubmissionID", + In: "path", + }: params.SubmissionID, + }, + Raw: r, + } + + type ( + Request = CheckList + Params = CreateSubmissionAuditCheckListParams + Response = *CreateSubmissionAuditCheckListNoContent + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackCreateSubmissionAuditCheckListParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + err = s.h.CreateSubmissionAuditCheckList(ctx, request, params) + return response, err + }, + ) + } else { + err = s.h.CreateSubmissionAuditCheckList(ctx, request, 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 := encodeCreateSubmissionAuditCheckListResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + // handleCreateSubmissionAuditErrorRequest handles createSubmissionAuditError operation. // // Validator posts an error to the audit log. diff --git a/pkg/internal/oas_json_gen.go b/pkg/internal/oas_json_gen.go index 9a74a1a..4f95e1c 100644 --- a/pkg/internal/oas_json_gen.go +++ b/pkg/internal/oas_json_gen.go @@ -12,6 +12,186 @@ import ( "github.com/ogen-go/ogen/validate" ) +// Encode implements json.Marshaler. +func (s *Check) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *Check) encodeFields(e *jx.Encoder) { + { + e.FieldStart("Name") + e.Str(s.Name) + } + { + e.FieldStart("Summary") + e.Str(s.Summary) + } + { + e.FieldStart("Passed") + e.Bool(s.Passed) + } +} + +var jsonFieldsNameOfCheck = [3]string{ + 0: "Name", + 1: "Summary", + 2: "Passed", +} + +// Decode decodes Check from json. +func (s *Check) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Check to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "Name": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Name = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"Name\"") + } + case "Summary": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Summary = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"Summary\"") + } + case "Passed": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Bool() + s.Passed = bool(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"Passed\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode Check") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfCheck) { + name = jsonFieldsNameOfCheck[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *Check) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Check) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes CheckList as json. +func (s CheckList) Encode(e *jx.Encoder) { + unwrapped := []Check(s) + + e.ArrStart() + for _, elem := range unwrapped { + elem.Encode(e) + } + e.ArrEnd() +} + +// Decode decodes CheckList from json. +func (s *CheckList) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode CheckList to nil") + } + var unwrapped []Check + if err := func() error { + unwrapped = make([]Check, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem Check + if err := elem.Decode(d); err != nil { + return err + } + unwrapped = append(unwrapped, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = CheckList(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s CheckList) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *CheckList) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *Error) Encode(e *jx.Encoder) { e.ObjStart() diff --git a/pkg/internal/oas_operations_gen.go b/pkg/internal/oas_operations_gen.go index 1ee0aac..a789b23 100644 --- a/pkg/internal/oas_operations_gen.go +++ b/pkg/internal/oas_operations_gen.go @@ -18,10 +18,12 @@ const ( ActionSubmissionUploadedOperation OperationName = "ActionSubmissionUploaded" ActionSubmissionValidatedOperation OperationName = "ActionSubmissionValidated" CreateMapfixOperation OperationName = "CreateMapfix" + CreateMapfixAuditCheckListOperation OperationName = "CreateMapfixAuditCheckList" CreateMapfixAuditErrorOperation OperationName = "CreateMapfixAuditError" CreateScriptOperation OperationName = "CreateScript" CreateScriptPolicyOperation OperationName = "CreateScriptPolicy" CreateSubmissionOperation OperationName = "CreateSubmission" + CreateSubmissionAuditCheckListOperation OperationName = "CreateSubmissionAuditCheckList" CreateSubmissionAuditErrorOperation OperationName = "CreateSubmissionAuditError" GetScriptOperation OperationName = "GetScript" ListScriptPolicyOperation OperationName = "ListScriptPolicy" diff --git a/pkg/internal/oas_parameters_gen.go b/pkg/internal/oas_parameters_gen.go index e8f11fd..7194603 100644 --- a/pkg/internal/oas_parameters_gen.go +++ b/pkg/internal/oas_parameters_gen.go @@ -1537,6 +1537,89 @@ func decodeActionSubmissionValidatedParams(args [1]string, argsEscaped bool, r * return params, nil } +// CreateMapfixAuditCheckListParams is parameters of createMapfixAuditCheckList operation. +type CreateMapfixAuditCheckListParams struct { + // The unique identifier for a submission. + MapfixID int64 +} + +func unpackCreateMapfixAuditCheckListParams(packed middleware.Parameters) (params CreateMapfixAuditCheckListParams) { + { + key := middleware.ParameterKey{ + Name: "MapfixID", + In: "path", + } + params.MapfixID = packed[key].(int64) + } + return params +} + +func decodeCreateMapfixAuditCheckListParams(args [1]string, argsEscaped bool, r *http.Request) (params CreateMapfixAuditCheckListParams, _ 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 +} + // CreateMapfixAuditErrorParams is parameters of createMapfixAuditError operation. type CreateMapfixAuditErrorParams struct { // The unique identifier for a submission. @@ -1681,6 +1764,89 @@ func decodeCreateMapfixAuditErrorParams(args [1]string, argsEscaped bool, r *htt return params, nil } +// CreateSubmissionAuditCheckListParams is parameters of createSubmissionAuditCheckList operation. +type CreateSubmissionAuditCheckListParams struct { + // The unique identifier for a submission. + SubmissionID int64 +} + +func unpackCreateSubmissionAuditCheckListParams(packed middleware.Parameters) (params CreateSubmissionAuditCheckListParams) { + { + key := middleware.ParameterKey{ + Name: "SubmissionID", + In: "path", + } + params.SubmissionID = packed[key].(int64) + } + return params +} + +func decodeCreateSubmissionAuditCheckListParams(args [1]string, argsEscaped bool, r *http.Request) (params CreateSubmissionAuditCheckListParams, _ 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 +} + // CreateSubmissionAuditErrorParams is parameters of createSubmissionAuditError operation. type CreateSubmissionAuditErrorParams struct { // The unique identifier for a submission. diff --git a/pkg/internal/oas_request_decoders_gen.go b/pkg/internal/oas_request_decoders_gen.go index 016e05d..3d02cd5 100644 --- a/pkg/internal/oas_request_decoders_gen.go +++ b/pkg/internal/oas_request_decoders_gen.go @@ -85,6 +85,77 @@ func (s *Server) decodeCreateMapfixRequest(r *http.Request) ( } } +func (s *Server) decodeCreateMapfixAuditCheckListRequest(r *http.Request) ( + req CheckList, + close func() error, + rerr error, +) { + var closers []func() error + close = func() error { + var merr error + // Close in reverse order, to match defer behavior. + for i := len(closers) - 1; i >= 0; i-- { + c := closers[i] + merr = errors.Join(merr, c()) + } + return merr + } + defer func() { + if rerr != nil { + rerr = errors.Join(rerr, close()) + } + }() + ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, close, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + if r.ContentLength == 0 { + return req, close, validate.ErrBodyRequired + } + buf, err := io.ReadAll(r.Body) + if err != nil { + return req, close, err + } + + if len(buf) == 0 { + return req, close, validate.ErrBodyRequired + } + + d := jx.DecodeBytes(buf) + + var request CheckList + if err := func() error { + if err := request.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return req, close, err + } + if err := func() error { + if err := request.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return req, close, errors.Wrap(err, "validate") + } + return request, close, nil + default: + return req, close, validate.InvalidContentType(ct) + } +} + func (s *Server) decodeCreateScriptRequest(r *http.Request) ( req *ScriptCreate, close func() error, @@ -297,3 +368,74 @@ func (s *Server) decodeCreateSubmissionRequest(r *http.Request) ( return req, close, validate.InvalidContentType(ct) } } + +func (s *Server) decodeCreateSubmissionAuditCheckListRequest(r *http.Request) ( + req CheckList, + close func() error, + rerr error, +) { + var closers []func() error + close = func() error { + var merr error + // Close in reverse order, to match defer behavior. + for i := len(closers) - 1; i >= 0; i-- { + c := closers[i] + merr = errors.Join(merr, c()) + } + return merr + } + defer func() { + if rerr != nil { + rerr = errors.Join(rerr, close()) + } + }() + ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, close, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + if r.ContentLength == 0 { + return req, close, validate.ErrBodyRequired + } + buf, err := io.ReadAll(r.Body) + if err != nil { + return req, close, err + } + + if len(buf) == 0 { + return req, close, validate.ErrBodyRequired + } + + d := jx.DecodeBytes(buf) + + var request CheckList + if err := func() error { + if err := request.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return req, close, err + } + if err := func() error { + if err := request.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return req, close, errors.Wrap(err, "validate") + } + return request, close, nil + default: + return req, close, validate.InvalidContentType(ct) + } +} diff --git a/pkg/internal/oas_request_encoders_gen.go b/pkg/internal/oas_request_encoders_gen.go index 1e38200..a819e46 100644 --- a/pkg/internal/oas_request_encoders_gen.go +++ b/pkg/internal/oas_request_encoders_gen.go @@ -25,6 +25,20 @@ func encodeCreateMapfixRequest( return nil } +func encodeCreateMapfixAuditCheckListRequest( + req CheckList, + r *http.Request, +) error { + const contentType = "application/json" + e := new(jx.Encoder) + { + req.Encode(e) + } + encoded := e.Bytes() + ht.SetBody(r, bytes.NewReader(encoded), contentType) + return nil +} + func encodeCreateScriptRequest( req *ScriptCreate, r *http.Request, @@ -66,3 +80,17 @@ func encodeCreateSubmissionRequest( ht.SetBody(r, bytes.NewReader(encoded), contentType) return nil } + +func encodeCreateSubmissionAuditCheckListRequest( + req CheckList, + r *http.Request, +) error { + const contentType = "application/json" + e := new(jx.Encoder) + { + req.Encode(e) + } + encoded := e.Bytes() + ht.SetBody(r, bytes.NewReader(encoded), contentType) + return nil +} diff --git a/pkg/internal/oas_response_decoders_gen.go b/pkg/internal/oas_response_decoders_gen.go index 28f2f29..c68aff2 100644 --- a/pkg/internal/oas_response_decoders_gen.go +++ b/pkg/internal/oas_response_decoders_gen.go @@ -776,6 +776,66 @@ func decodeCreateMapfixResponse(resp *http.Response) (res *MapfixID, _ error) { return res, errors.Wrap(defRes, "error") } +func decodeCreateMapfixAuditCheckListResponse(resp *http.Response) (res *CreateMapfixAuditCheckListNoContent, _ error) { + switch resp.StatusCode { + case 204: + // Code 204. + return &CreateMapfixAuditCheckListNoContent{}, 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 decodeCreateMapfixAuditErrorResponse(resp *http.Response) (res *CreateMapfixAuditErrorNoContent, _ error) { switch resp.StatusCode { case 204: @@ -1139,6 +1199,66 @@ func decodeCreateSubmissionResponse(resp *http.Response) (res *SubmissionID, _ e return res, errors.Wrap(defRes, "error") } +func decodeCreateSubmissionAuditCheckListResponse(resp *http.Response) (res *CreateSubmissionAuditCheckListNoContent, _ error) { + switch resp.StatusCode { + case 204: + // Code 204. + return &CreateSubmissionAuditCheckListNoContent{}, 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 decodeCreateSubmissionAuditErrorResponse(resp *http.Response) (res *CreateSubmissionAuditErrorNoContent, _ error) { switch resp.StatusCode { case 204: diff --git a/pkg/internal/oas_response_encoders_gen.go b/pkg/internal/oas_response_encoders_gen.go index 5cde190..00268a8 100644 --- a/pkg/internal/oas_response_encoders_gen.go +++ b/pkg/internal/oas_response_encoders_gen.go @@ -104,6 +104,13 @@ func encodeCreateMapfixResponse(response *MapfixID, w http.ResponseWriter, span return nil } +func encodeCreateMapfixAuditCheckListResponse(response *CreateMapfixAuditCheckListNoContent, w http.ResponseWriter, span trace.Span) error { + w.WriteHeader(204) + span.SetStatus(codes.Ok, http.StatusText(204)) + + return nil +} + func encodeCreateMapfixAuditErrorResponse(response *CreateMapfixAuditErrorNoContent, w http.ResponseWriter, span trace.Span) error { w.WriteHeader(204) span.SetStatus(codes.Ok, http.StatusText(204)) @@ -153,6 +160,13 @@ func encodeCreateSubmissionResponse(response *SubmissionID, w http.ResponseWrite return nil } +func encodeCreateSubmissionAuditCheckListResponse(response *CreateSubmissionAuditCheckListNoContent, w http.ResponseWriter, span trace.Span) error { + w.WriteHeader(204) + span.SetStatus(codes.Ok, http.StatusText(204)) + + return nil +} + func encodeCreateSubmissionAuditErrorResponse(response *CreateSubmissionAuditErrorNoContent, w http.ResponseWriter, span trace.Span) error { w.WriteHeader(204) span.SetStatus(codes.Ok, http.StatusText(204)) diff --git a/pkg/internal/oas_router_gen.go b/pkg/internal/oas_router_gen.go index a6615a9..e89474a 100644 --- a/pkg/internal/oas_router_gen.go +++ b/pkg/internal/oas_router_gen.go @@ -113,6 +113,28 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { break } switch elem[0] { + case 'c': // Prefix: "checklist" + + if l := len("checklist"); len(elem) >= l && elem[0:l] == "checklist" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "POST": + s.handleCreateMapfixAuditCheckListRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, "POST") + } + + return + } + case 'e': // Prefix: "error" if l := len("error"); len(elem) >= l && elem[0:l] == "error" { @@ -486,6 +508,28 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { break } switch elem[0] { + case 'c': // Prefix: "checklist" + + if l := len("checklist"); len(elem) >= l && elem[0:l] == "checklist" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "POST": + s.handleCreateSubmissionAuditCheckListRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, "POST") + } + + return + } + case 'e': // Prefix: "error" if l := len("error"); len(elem) >= l && elem[0:l] == "error" { @@ -812,6 +856,30 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { break } switch elem[0] { + case 'c': // Prefix: "checklist" + + if l := len("checklist"); len(elem) >= l && elem[0:l] == "checklist" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "POST": + r.name = CreateMapfixAuditCheckListOperation + r.summary = "Validator posts a checklist to the audit log" + r.operationID = "createMapfixAuditCheckList" + r.pathPattern = "/mapfixes/{MapfixID}/checklist" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + case 'e': // Prefix: "error" if l := len("error"); len(elem) >= l && elem[0:l] == "error" { @@ -1227,6 +1295,30 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { break } switch elem[0] { + case 'c': // Prefix: "checklist" + + if l := len("checklist"); len(elem) >= l && elem[0:l] == "checklist" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "POST": + r.name = CreateSubmissionAuditCheckListOperation + r.summary = "Validator posts a checklist to the audit log" + r.operationID = "createSubmissionAuditCheckList" + r.pathPattern = "/submissions/{SubmissionID}/checklist" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + case 'e': // Prefix: "error" if l := len("error"); len(elem) >= l && elem[0:l] == "error" { diff --git a/pkg/internal/oas_schemas_gen.go b/pkg/internal/oas_schemas_gen.go index 16c6e42..bcca85e 100644 --- a/pkg/internal/oas_schemas_gen.go +++ b/pkg/internal/oas_schemas_gen.go @@ -43,9 +43,54 @@ type ActionSubmissionUploadedNoContent struct{} // ActionSubmissionValidatedNoContent is response for ActionSubmissionValidated operation. type ActionSubmissionValidatedNoContent struct{} +// Ref: #/components/schemas/Check +type Check struct { + Name string `json:"Name"` + Summary string `json:"Summary"` + Passed bool `json:"Passed"` +} + +// GetName returns the value of Name. +func (s *Check) GetName() string { + return s.Name +} + +// GetSummary returns the value of Summary. +func (s *Check) GetSummary() string { + return s.Summary +} + +// GetPassed returns the value of Passed. +func (s *Check) GetPassed() bool { + return s.Passed +} + +// SetName sets the value of Name. +func (s *Check) SetName(val string) { + s.Name = val +} + +// SetSummary sets the value of Summary. +func (s *Check) SetSummary(val string) { + s.Summary = val +} + +// SetPassed sets the value of Passed. +func (s *Check) SetPassed(val bool) { + s.Passed = val +} + +type CheckList []Check + +// CreateMapfixAuditCheckListNoContent is response for CreateMapfixAuditCheckList operation. +type CreateMapfixAuditCheckListNoContent struct{} + // CreateMapfixAuditErrorNoContent is response for CreateMapfixAuditError operation. type CreateMapfixAuditErrorNoContent struct{} +// CreateSubmissionAuditCheckListNoContent is response for CreateSubmissionAuditCheckList operation. +type CreateSubmissionAuditCheckListNoContent struct{} + // CreateSubmissionAuditErrorNoContent is response for CreateSubmissionAuditError operation. type CreateSubmissionAuditErrorNoContent struct{} diff --git a/pkg/internal/oas_server_gen.go b/pkg/internal/oas_server_gen.go index b091f81..58f028f 100644 --- a/pkg/internal/oas_server_gen.go +++ b/pkg/internal/oas_server_gen.go @@ -80,6 +80,12 @@ type Handler interface { // // POST /mapfixes CreateMapfix(ctx context.Context, req *MapfixCreate) (*MapfixID, error) + // CreateMapfixAuditCheckList implements createMapfixAuditCheckList operation. + // + // Validator posts a checklist to the audit log. + // + // POST /mapfixes/{MapfixID}/checklist + CreateMapfixAuditCheckList(ctx context.Context, req CheckList, params CreateMapfixAuditCheckListParams) error // CreateMapfixAuditError implements createMapfixAuditError operation. // // Validator posts an error to the audit log. @@ -104,6 +110,12 @@ type Handler interface { // // POST /submissions CreateSubmission(ctx context.Context, req *SubmissionCreate) (*SubmissionID, error) + // CreateSubmissionAuditCheckList implements createSubmissionAuditCheckList operation. + // + // Validator posts a checklist to the audit log. + // + // POST /submissions/{SubmissionID}/checklist + CreateSubmissionAuditCheckList(ctx context.Context, req CheckList, params CreateSubmissionAuditCheckListParams) error // CreateSubmissionAuditError implements createSubmissionAuditError operation. // // Validator posts an error to the audit log. diff --git a/pkg/internal/oas_unimplemented_gen.go b/pkg/internal/oas_unimplemented_gen.go index 666fc14..6257f15 100644 --- a/pkg/internal/oas_unimplemented_gen.go +++ b/pkg/internal/oas_unimplemented_gen.go @@ -121,6 +121,15 @@ func (UnimplementedHandler) CreateMapfix(ctx context.Context, req *MapfixCreate) return r, ht.ErrNotImplemented } +// CreateMapfixAuditCheckList implements createMapfixAuditCheckList operation. +// +// Validator posts a checklist to the audit log. +// +// POST /mapfixes/{MapfixID}/checklist +func (UnimplementedHandler) CreateMapfixAuditCheckList(ctx context.Context, req CheckList, params CreateMapfixAuditCheckListParams) error { + return ht.ErrNotImplemented +} + // CreateMapfixAuditError implements createMapfixAuditError operation. // // Validator posts an error to the audit log. @@ -157,6 +166,15 @@ func (UnimplementedHandler) CreateSubmission(ctx context.Context, req *Submissio return r, ht.ErrNotImplemented } +// CreateSubmissionAuditCheckList implements createSubmissionAuditCheckList operation. +// +// Validator posts a checklist to the audit log. +// +// POST /submissions/{SubmissionID}/checklist +func (UnimplementedHandler) CreateSubmissionAuditCheckList(ctx context.Context, req CheckList, params CreateSubmissionAuditCheckListParams) error { + return ht.ErrNotImplemented +} + // CreateSubmissionAuditError implements createSubmissionAuditError operation. // // Validator posts an error to the audit log. diff --git a/pkg/internal/oas_validators_gen.go b/pkg/internal/oas_validators_gen.go index 86e8c6a..2e5d084 100644 --- a/pkg/internal/oas_validators_gen.go +++ b/pkg/internal/oas_validators_gen.go @@ -3,11 +3,88 @@ package api import ( + "fmt" + "github.com/go-faster/errors" "github.com/ogen-go/ogen/validate" ) +func (s *Check) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.String{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 128, + MaxLengthSet: true, + Email: false, + Hostname: false, + Regex: nil, + }).Validate(string(s.Name)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "Name", + Error: err, + }) + } + if err := func() error { + if err := (validate.String{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 4096, + MaxLengthSet: true, + Email: false, + Hostname: false, + Regex: nil, + }).Validate(string(s.Summary)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "Summary", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s CheckList) Validate() error { + alias := ([]Check)(s) + if alias == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range alias { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + func (s *Error) Validate() error { if s == nil { return validate.ErrNilPointer diff --git a/pkg/model/audit_event.go b/pkg/model/audit_event.go index 53b0632..f9550bc 100644 --- a/pkg/model/audit_event.go +++ b/pkg/model/audit_event.go @@ -48,6 +48,19 @@ type AuditEventDataError struct { Error string `json:"error"` } +type Check struct { + Name string `json:"name"` + Summary string `json:"summary"` + Passed bool `json:"passed"` +} + +// Validator map checks details +const AuditEventTypeCheckList AuditEventType = 7 + +type AuditEventDataCheckList struct { + CheckList []Check `json:"check_list"` +} + type AuditEvent struct { ID int64 `gorm:"primaryKey"` CreatedAt time.Time diff --git a/pkg/service_internal/audit_events.go b/pkg/service_internal/audit_events.go index 82e6dd6..53e386c 100644 --- a/pkg/service_internal/audit_events.go +++ b/pkg/service_internal/audit_events.go @@ -69,3 +69,24 @@ func (svc *Service) CreateAuditEventError(ctx context.Context, userId uint64, re return nil } + +func (svc *Service) CreateAuditEventCheckList(ctx context.Context, userId uint64, resource model.Resource, event_data model.AuditEventDataCheckList) error { + EventData, err := json.Marshal(event_data) + if err != nil { + return err + } + + _, err = svc.DB.AuditEvents().Create(ctx, model.AuditEvent{ + ID: 0, + User: userId, + ResourceType: resource.Type, + ResourceID: resource.ID, + EventType: model.AuditEventTypeCheckList, + EventData: EventData, + }) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/service_internal/mapfixes.go b/pkg/service_internal/mapfixes.go index 7c44a89..ad8e1e3 100644 --- a/pkg/service_internal/mapfixes.go +++ b/pkg/service_internal/mapfixes.go @@ -222,6 +222,36 @@ func (svc *Service) CreateMapfixAuditError(ctx context.Context, params internal. ) } +// CreateMapfixAuditCheckList implements createMapfixAuditCheckList operation. +// +// Post a checklist to the audit log +// +// POST /mapfixes/{MapfixID}/checklist +func (svc *Service) CreateMapfixAuditCheckList(ctx context.Context, check_list internal.CheckList, params internal.CreateMapfixAuditCheckListParams) (error) { + check_list2 := make([]model.Check, len(check_list)) + for i, check := range check_list { + check_list2[i] = model.Check{ + Name: check.Name, + Summary: check.Summary, + Passed: check.Passed, + } + } + + event_data := model.AuditEventDataCheckList{ + CheckList: check_list2, + } + + return svc.CreateAuditEventCheckList( + ctx, + ValidtorUserID, + model.Resource{ + ID: params.MapfixID, + Type: model.ResourceMapfix, + }, + event_data, + ) +} + // POST /mapfixes func (svc *Service) CreateMapfix(ctx context.Context, request *internal.MapfixCreate) (*internal.MapfixID, error) { // sanitization diff --git a/pkg/service_internal/submissions.go b/pkg/service_internal/submissions.go index 910b8f8..c7c18a7 100644 --- a/pkg/service_internal/submissions.go +++ b/pkg/service_internal/submissions.go @@ -242,6 +242,36 @@ func (svc *Service) CreateSubmissionAuditError(ctx context.Context, params inter ) } +// CreateSubmissionAuditCheckList implements createSubmissionAuditCheckList operation. +// +// Post a checklist to the audit log +// +// POST /submissions/{SubmissionID}/checklist +func (svc *Service) CreateSubmissionAuditCheckList(ctx context.Context, check_list internal.CheckList, params internal.CreateSubmissionAuditCheckListParams) (error) { + check_list2 := make([]model.Check, len(check_list)) + for i, check := range check_list { + check_list2[i] = model.Check{ + Name: check.Name, + Summary: check.Summary, + Passed: check.Passed, + } + } + + event_data := model.AuditEventDataCheckList{ + CheckList: check_list2, + } + + return svc.CreateAuditEventCheckList( + ctx, + ValidtorUserID, + model.Resource{ + ID: params.SubmissionID, + Type: model.ResourceSubmission, + }, + event_data, + ) +} + // POST /submissions func (svc *Service) CreateSubmission(ctx context.Context, request *internal.SubmissionCreate) (*internal.SubmissionID, error) { // sanitization diff --git a/validation/api/src/internal.rs b/validation/api/src/internal.rs index 28fe19c..773c8ac 100644 --- a/validation/api/src/internal.rs +++ b/validation/api/src/internal.rs @@ -161,6 +161,18 @@ impl Context{ ).await.map_err(Error::Response)? .json().await.map_err(Error::ReqwestJson) } + pub async fn create_submission_audit_check_list(&self,config:CreateSubmissionAuditCheckListRequest<'_>)->Result<(),Error>{ + let url_raw=format!("{}/submissions/{}/checklist",self.0.base_url,config.SubmissionID.0); + let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?; + + let body=serde_json::to_string(&config.CheckList).map_err(Error::JSON)?; + + response_ok( + self.0.post(url,body).await.map_err(Error::Reqwest)? + ).await.map_err(Error::Response)?; + + Ok(()) + } // simple submission endpoints action!("submissions",action_submission_request_changes,config,ActionSubmissionRequestChangesRequest,"status/validator-request-changes",config.SubmissionID.0,); action!("submissions",action_submission_submitted,config,ActionSubmissionSubmittedRequest,"status/validator-submitted",config.SubmissionID.0, @@ -192,6 +204,18 @@ impl Context{ ).await.map_err(Error::Response)? .json().await.map_err(Error::ReqwestJson) } + pub async fn create_mapfix_audit_check_list(&self,config:CreateMapfixAuditCheckListRequest<'_>)->Result<(),Error>{ + let url_raw=format!("{}/mapfixes/{}/checklist",self.0.base_url,config.MapfixID.0); + let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?; + + let body=serde_json::to_string(&config.CheckList).map_err(Error::JSON)?; + + response_ok( + self.0.post(url,body).await.map_err(Error::Reqwest)? + ).await.map_err(Error::Response)?; + + Ok(()) + } // simple mapfixes endpoints action!("mapfixes",action_mapfix_request_changes,config,ActionMapfixRequestChangesRequest,"status/validator-request-changes",config.MapfixID.0,); action!("mapfixes",action_mapfix_submitted,config,ActionMapfixSubmittedRequest,"status/validator-submitted",config.MapfixID.0, diff --git a/validation/api/src/types.rs b/validation/api/src/types.rs index 0ccb5d1..afe058e 100644 --- a/validation/api/src/types.rs +++ b/validation/api/src/types.rs @@ -334,6 +334,14 @@ pub struct MapResponse{ pub Date:i64, } +#[allow(nonstandard_style)] +#[derive(Clone,Debug,serde::Serialize)] +pub struct Check{ + pub Name:&'static str, + pub Summary:String, + pub Passed:bool, +} + #[allow(nonstandard_style)] #[derive(Clone,Debug)] pub struct ActionSubmissionSubmittedRequest{ @@ -370,6 +378,13 @@ pub struct CreateSubmissionAuditErrorRequest{ pub ErrorMessage:String, } +#[allow(nonstandard_style)] +#[derive(Clone,Debug)] +pub struct CreateSubmissionAuditCheckListRequest<'a>{ + pub SubmissionID:SubmissionID, + pub CheckList:&'a [Check], +} + #[derive(Clone,Copy,Debug,serde::Serialize,serde::Deserialize)] pub struct SubmissionID(pub(crate)i64); @@ -416,6 +431,13 @@ pub struct CreateMapfixAuditErrorRequest{ pub ErrorMessage:String, } +#[allow(nonstandard_style)] +#[derive(Clone,Debug)] +pub struct CreateMapfixAuditCheckListRequest<'a>{ + pub MapfixID:MapfixID, + pub CheckList:&'a [Check], +} + #[derive(Clone,Copy,Debug,serde::Serialize,serde::Deserialize)] pub struct MapfixID(pub(crate)i64); diff --git a/validation/src/check.rs b/validation/src/check.rs index 17121fe..6a56b92 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -3,6 +3,7 @@ use crate::download::download_asset_version; use crate::rbx_util::{get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError}; use heck::{ToSnakeCase,ToTitleCase}; +use submissions_api::types::Check; #[allow(dead_code)] #[derive(Debug)] @@ -611,40 +612,31 @@ impl std::fmt::Display for 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! passed{ + ($name:literal)=>{ + Check{ + Name:$name, + Summary:String::new(), + Passed:true, } } } macro_rules! summary{ - ($name:literal,$summary:expr,$details:expr)=>{ - CheckSummary{ - name:$name, - summary:$summary, - passed:false, - details:serde_json::to_value($details)?, + ($name:literal,$summary:expr)=>{ + Check{ + Name:$name, + Summary:$summary, + Passed:false, } }; } macro_rules! summary_format{ - ($name:literal,$fmt:literal,$details:expr)=>{ - CheckSummary{ - name:$name, - summary:format!($fmt), - passed:false, - details:serde_json::to_value($details)?, + ($name:literal,$fmt:literal)=>{ + Check{ + Name:$name, + Summary:format!($fmt), + Passed:false, } }; } @@ -654,134 +646,134 @@ macro_rules! summary_format{ 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}",()), + StringCheck(Ok(()))=>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}",()), + StringCheck(Ok(()))=>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(),()), + Ok(Ok(StringCheck(Ok(_))))=>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(),()), + Ok(Ok(_))=>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(),()), + Ok(_)=>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",()), + Ok(Exists)=>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(Ok(()))=>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}",()) + summary_format!("DuplicateStart","Duplicate start zones: {context}") } }; let (extra_finish,missing_finish)=match &self.mode_finish_counts{ - SetDifferenceCheck(Ok(()))=>(CheckSummary::passed("ExtraFinish"),CheckSummary::passed("MissingFinish")), + SetDifferenceCheck(Ok(()))=>(passed!("DanglingFinish"),passed!("MissingFinish")), SetDifferenceCheck(Err(context))=>( if context.extra.is_empty(){ - CheckSummary::passed("ExtraFinish") + passed!("DanglingFinish") }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}",()) + summary_format!("DanglingFinish","No matching start zone for finish {plural}: {context}") }, if context.missing.is_empty(){ - CheckSummary::passed("MissingFinish") + 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}",()) + summary_format!("MissingFinish","Missing finish {plural}: {context}") } ), }; let dangling_anticheat=match &self.mode_anticheat_counts{ - SetDifferenceCheck(Ok(()))=>CheckSummary::passed("DanglingAnticheat"), + SetDifferenceCheck(Ok(()))=>passed!("DanglingAnticheat"), SetDifferenceCheck(Err(context))=>{ if context.extra.is_empty(){ - CheckSummary::passed("DanglingAnticheat") + 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}",()) + summary_format!("DanglingAnticheat","No matching start zone for anticheat {plural}: {context}") } } }; let spawn1=match &self.spawn1{ - Ok(Exists)=>CheckSummary::passed("Spawn1"), - Err(Absent)=>summary_format!("Spawn1","Model has no Spawn1",()), + Ok(Exists)=>passed!("Spawn1"), + Err(Absent)=>summary_format!("Spawn1","Model has no Spawn1"), }; let dangling_teleport=match &self.teleport_counts{ - SetDifferenceCheck(Ok(()))=>CheckSummary::passed("DanglingTeleport"), + SetDifferenceCheck(Ok(()))=>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}",()) + summary_format!("DanglingTeleport","No matching Spawn for {plural}: {context}") } }; let duplicate_spawns=match &self.spawn_counts{ - DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateSpawn"), + DuplicateCheck(Ok(()))=>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}",()) + summary_format!("DuplicateSpawn","Duplicate Spawn: {context}") } }; let (extra_wormhole_in,missing_wormhole_in)=match &self.wormhole_in_counts{ - SetDifferenceCheck(Ok(()))=>(CheckSummary::passed("ExtraWormholeIn"),CheckSummary::passed("MissingWormholeIn")), + SetDifferenceCheck(Ok(()))=>(passed!("ExtraWormholeIn"),passed!("MissingWormholeIn")), SetDifferenceCheck(Err(context))=>( if context.extra.is_empty(){ - CheckSummary::passed("ExtraWormholeIn") + 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}",()) + summary_format!("ExtraWormholeIn","WormholeIn with no matching WormholeOut: {context}") }, if context.missing.is_empty(){ - CheckSummary::passed("MissingWormholeIn") + 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}",()) + summary_format!("MissingWormholeIn","WormholeOut with no matching WormholeIn: {context}") } ) }; let duplicate_wormhole_out=match &self.wormhole_out_counts{ - DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateWormholeOut"), + DuplicateCheck(Ok(()))=>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}",()) + summary_format!("DuplicateWormholeOut","Duplicate WormholeOut: {context}") } }; Ok(MapCheckList{checks:Box::new([ @@ -806,29 +798,17 @@ impl MapCheck<'_>{ } #[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 MapCheckList{ + pub checks:Box<[Check;16]>, } -pub struct Summary{ - pub summary:String, - pub json:serde_json::Value, -} - -pub struct CheckReportAndVersion{ - pub status:Result, +pub struct CheckListAndVersion{ + pub status:Result, pub version:u64, } impl crate::message_handler::MessageHandler{ - pub async fn check_inner(&self,check_info:CheckRequest)->Result{ + pub async fn check_inner(&self,check_info:CheckRequest)->Result{ // discover asset creator and latest version let info=self.cloud_context.get_asset_info( rbx_asset::cloud::GetAssetLatestRequest{asset_id:check_info.ModelID} @@ -873,13 +853,10 @@ impl crate::message_handler::MessageHandler{ // check the report, generate an error message if it fails the check 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(Ok(check_list))=>Err(check_list), Err(Err(e))=>return Err(Error::ToJsonValue(e)), }; - Ok(CheckReportAndVersion{status,version}) + Ok(CheckListAndVersion{status,version}) } } diff --git a/validation/src/check_mapfix.rs b/validation/src/check_mapfix.rs index e66b323..ce0e840 100644 --- a/validation/src/check_mapfix.rs +++ b/validation/src/check_mapfix.rs @@ -1,4 +1,4 @@ -use crate::check::CheckReportAndVersion; +use crate::check::CheckListAndVersion; use crate::nats_types::CheckMapfixRequest; #[allow(dead_code)] @@ -21,7 +21,7 @@ impl crate::message_handler::MessageHandler{ // update the mapfix depending on the result match check_result{ - Ok(CheckReportAndVersion{status:Ok(map_info),version})=>{ + Ok(CheckListAndVersion{status:Ok(map_info),version})=>{ self.api.action_mapfix_submitted( submissions_api::types::ActionMapfixSubmittedRequest{ MapfixID:mapfix_id, @@ -36,10 +36,10 @@ impl crate::message_handler::MessageHandler{ return Ok(()); }, // update the mapfix model status to request changes - Ok(CheckReportAndVersion{status:Err(report),..})=>self.api.create_mapfix_audit_error( - submissions_api::types::CreateMapfixAuditErrorRequest{ + Ok(CheckListAndVersion{status:Err(check_list),..})=>self.api.create_mapfix_audit_check_list( + submissions_api::types::CreateMapfixAuditCheckListRequest{ MapfixID:mapfix_id, - ErrorMessage:report.summary, + CheckList:check_list.checks.as_slice(), } ).await.map_err(Error::ApiActionMapfixCheck)?, // update the mapfix model status to request changes diff --git a/validation/src/check_submission.rs b/validation/src/check_submission.rs index 596f8e0..7f838b2 100644 --- a/validation/src/check_submission.rs +++ b/validation/src/check_submission.rs @@ -1,4 +1,4 @@ -use crate::check::CheckReportAndVersion; +use crate::check::CheckListAndVersion; use crate::nats_types::CheckSubmissionRequest; #[allow(dead_code)] @@ -22,7 +22,7 @@ impl crate::message_handler::MessageHandler{ // update the submission depending on the result match check_result{ // update the submission model status to submitted - Ok(CheckReportAndVersion{status:Ok(map_info),version})=>{ + Ok(CheckListAndVersion{status:Ok(map_info),version})=>{ self.api.action_submission_submitted( submissions_api::types::ActionSubmissionSubmittedRequest{ SubmissionID:submission_id, @@ -37,10 +37,10 @@ impl crate::message_handler::MessageHandler{ return Ok(()); }, // update the submission model status to request changes - Ok(CheckReportAndVersion{status:Err(report),..})=>self.api.create_submission_audit_error( - submissions_api::types::CreateSubmissionAuditErrorRequest{ + Ok(CheckListAndVersion{status:Err(check_list),..})=>self.api.create_submission_audit_check_list( + submissions_api::types::CreateSubmissionAuditCheckListRequest{ SubmissionID:submission_id, - ErrorMessage:report.summary, + CheckList:check_list.checks.as_slice(), } ).await.map_err(Error::ApiActionSubmissionCheck)?, // update the submission model status to request changes diff --git a/web/src/app/ts/AuditEvent.ts b/web/src/app/ts/AuditEvent.ts index d7b506f..cb99149 100644 --- a/web/src/app/ts/AuditEvent.ts +++ b/web/src/app/ts/AuditEvent.ts @@ -9,6 +9,7 @@ export const enum AuditEventType { ChangeDisplayName = 4, ChangeCreator = 5, Error = 6, + CheckList = 7, } // Discriminated union types for each event @@ -19,7 +20,8 @@ export type AuditEventData = | { EventType: AuditEventType.ChangeValidatedModel; EventData: AuditEventDataChangeValidatedModel; } | { EventType: AuditEventType.ChangeDisplayName; EventData: AuditEventDataChangeName; } | { EventType: AuditEventType.ChangeCreator; EventData: AuditEventDataChangeName; } - | { EventType: AuditEventType.Error; EventData: AuditEventDataError }; + | { EventType: AuditEventType.Error; EventData: AuditEventDataError; } + | { EventType: AuditEventType.CheckList; EventData: AuditEventDataCheckList; }; // Concrete data interfaces export interface AuditEventDataAction { @@ -51,6 +53,15 @@ export interface AuditEventDataError { error: string; } +export interface AuditEventDataCheck { + name: string + summary: string + passed: boolean +} +export interface AuditEventDataCheckList { + check_list: [AuditEventDataCheck]; +} + // Full audit event type (mirroring the Go struct) export interface AuditEvent { Id: number; @@ -87,6 +98,13 @@ export function decodeAuditEvent(event: AuditEvent): string { }case AuditEventType.Error:{ const data = event.EventData as AuditEventDataError; return `Error: ${data.error}`; + }case AuditEventType.CheckList:{ + const data = event.EventData as AuditEventDataCheckList; + const failedSummaries = data.check_list + .filter(check => !check.passed) + .map(check => check.summary) + .join('; '); + return `CheckList: ${failedSummaries}`; } default: throw new Error(`Unknown EventType: ${event.EventType}`); -- 2.49.1 From 42cc7838875bf80d8d47ef7a556888b03801a4c6 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Fri, 13 Jun 2025 22:04:25 -0700 Subject: [PATCH 3/7] validation: fixups --- validation/src/check.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/validation/src/check.rs b/validation/src/check.rs index 6a56b92..34a7743 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -387,6 +387,7 @@ pub struct MapInfoOwned{ pub creator:String, pub game_id:GameID, } +#[allow(dead_code)] #[derive(Debug)] pub enum IntoMapInfoOwnedError{ DisplayName(StringValueError), @@ -841,7 +842,7 @@ impl crate::message_handler::MessageHandler{ let status=Ok(map_info_owned); // return early - return Ok(CheckReportAndVersion{status,version}); + return Ok(CheckListAndVersion{status,version}); } // extract information from the model -- 2.49.1 From 51f62f039bdf09da99e9d3f52f542e17f6798ee7 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 18 Jun 2025 03:55:03 -0700 Subject: [PATCH 4/7] submissions-api: report script IDs not the whole thing --- validation/api/src/external.rs | 4 ++-- validation/api/src/internal.rs | 4 ++-- validation/api/src/types.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/validation/api/src/external.rs b/validation/api/src/external.rs index 8523d12..e32655c 100644 --- a/validation/api/src/external.rs +++ b/validation/api/src/external.rs @@ -57,7 +57,7 @@ impl Context{ ResourceID:None, }).await.map_err(SingleItemError::Other)?; if 1 std::error::Error for SingleItemError where Items:std::fmt::Debug{} -pub type ScriptSingleItemError=SingleItemError>; -pub type ScriptPolicySingleItemError=SingleItemError>; +pub type ScriptSingleItemError=SingleItemError>; +pub type ScriptPolicySingleItemError=SingleItemError>; #[allow(dead_code)] #[derive(Debug)] -- 2.49.1 From 53cc4b9e9e43569c4b2bd67438db7267199f4a3d Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 24 Jun 2025 05:09:51 +0000 Subject: [PATCH 5/7] Map Download Button (#201) Closes #145. All the backend should be implemented here, ~~I just don't know how to make a download button on the frontend.~~ I made a button, we'll see if it works. - [x] ~~Add asset download api key to infra~~ this was never required Reviewed-on: https://git.itzana.me/StrafesNET/maps-service/pulls/201 Co-authored-by: Quaternions Co-committed-by: Quaternions --- compose.yaml | 2 + openapi.yaml | 28 +++ pkg/api/oas_client_gen.go | 130 ++++++++++++++ pkg/api/oas_handlers_gen.go | 195 +++++++++++++++++++++ pkg/api/oas_operations_gen.go | 1 + pkg/api/oas_parameters_gen.go | 82 +++++++++ pkg/api/oas_response_decoders_gen.go | 77 ++++++++ pkg/api/oas_response_encoders_gen.go | 17 ++ pkg/api/oas_router_gen.go | 72 ++++++-- pkg/api/oas_schemas_gen.go | 14 ++ pkg/api/oas_security_gen.go | 1 + pkg/api/oas_server_gen.go | 6 + pkg/api/oas_unimplemented_gen.go | 9 + pkg/cmds/serve.go | 11 ++ pkg/roblox/asset_location.go | 72 ++++++++ pkg/service/maps.go | 28 +++ pkg/service/service.go | 2 + web/src/app/_components/downloadButton.tsx | 69 ++++++++ web/src/app/maps/[mapId]/page.tsx | 27 ++- 19 files changed, 830 insertions(+), 13 deletions(-) create mode 100644 pkg/roblox/asset_location.go create mode 100644 web/src/app/_components/downloadButton.tsx diff --git a/compose.yaml b/compose.yaml index e4ddfac..a1b9fc2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,6 +33,8 @@ services: "--auth-rpc-host","authrpc:8081", "--data-rpc-host","dataservice:9000", ] + env_file: + - ../auth-compose/strafesnet_staging.env depends_on: - authrpc - nats diff --git a/openapi.yaml b/openapi.yaml index f3008a5..85ca8e7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -158,6 +158,34 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /maps/{MapID}/location: + get: + summary: Get location of asset + operationId: getMapAssetLocation + tags: + - Maps + parameters: + - name: MapID + in: path + required: true + schema: + type: integer + format: int64 + minimum: 0 + responses: + "200": + description: Successful response + content: + text/plain: + schema: + type: string + maxLength: 1024 + default: + description: General Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /mapfixes: get: summary: Get list of mapfixes diff --git a/pkg/api/oas_client_gen.go b/pkg/api/oas_client_gen.go index 98464be..567b71f 100644 --- a/pkg/api/oas_client_gen.go +++ b/pkg/api/oas_client_gen.go @@ -223,6 +223,12 @@ type Invoker interface { // // GET /maps/{MapID} GetMap(ctx context.Context, params GetMapParams) (*Map, error) + // GetMapAssetLocation invokes getMapAssetLocation operation. + // + // Get location of asset. + // + // GET /maps/{MapID}/location + GetMapAssetLocation(ctx context.Context, params GetMapAssetLocationParams) (GetMapAssetLocationOK, error) // GetMapfix invokes getMapfix operation. // // Retrieve map with ID. @@ -4266,6 +4272,130 @@ func (c *Client) sendGetMap(ctx context.Context, params GetMapParams) (res *Map, return result, nil } +// GetMapAssetLocation invokes getMapAssetLocation operation. +// +// Get location of asset. +// +// GET /maps/{MapID}/location +func (c *Client) GetMapAssetLocation(ctx context.Context, params GetMapAssetLocationParams) (GetMapAssetLocationOK, error) { + res, err := c.sendGetMapAssetLocation(ctx, params) + return res, err +} + +func (c *Client) sendGetMapAssetLocation(ctx context.Context, params GetMapAssetLocationParams) (res GetMapAssetLocationOK, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getMapAssetLocation"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/maps/{MapID}/location"), + } + + // 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, GetMapAssetLocationOperation, + 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] = "/maps/" + { + // Encode "MapID" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "MapID", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.Int64ToString(params.MapID)) + }(); 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] = "/location" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", 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, GetMapAssetLocationOperation, 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 := decodeGetMapAssetLocationResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + // GetMapfix invokes getMapfix operation. // // Retrieve map with ID. diff --git a/pkg/api/oas_handlers_gen.go b/pkg/api/oas_handlers_gen.go index aad4887..7a36a93 100644 --- a/pkg/api/oas_handlers_gen.go +++ b/pkg/api/oas_handlers_gen.go @@ -6256,6 +6256,201 @@ func (s *Server) handleGetMapRequest(args [1]string, argsEscaped bool, w http.Re } } +// handleGetMapAssetLocationRequest handles getMapAssetLocation operation. +// +// Get location of asset. +// +// GET /maps/{MapID}/location +func (s *Server) handleGetMapAssetLocationRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getMapAssetLocation"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/maps/{MapID}/location"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), GetMapAssetLocationOperation, + 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: GetMapAssetLocationOperation, + ID: "getMapAssetLocation", + } + ) + { + type bitset = [1]uint8 + var satisfied bitset + { + sctx, ok, err := s.securityCookieAuth(ctx, GetMapAssetLocationOperation, 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 := decodeGetMapAssetLocationParams(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 GetMapAssetLocationOK + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: GetMapAssetLocationOperation, + OperationSummary: "Get location of asset", + OperationID: "getMapAssetLocation", + Body: nil, + Params: middleware.Parameters{ + { + Name: "MapID", + In: "path", + }: params.MapID, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = GetMapAssetLocationParams + Response = GetMapAssetLocationOK + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackGetMapAssetLocationParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.GetMapAssetLocation(ctx, params) + return response, err + }, + ) + } else { + response, err = s.h.GetMapAssetLocation(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 := encodeGetMapAssetLocationResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + // handleGetMapfixRequest handles getMapfix operation. // // Retrieve map with ID. diff --git a/pkg/api/oas_operations_gen.go b/pkg/api/oas_operations_gen.go index 80ed8f5..e448a29 100644 --- a/pkg/api/oas_operations_gen.go +++ b/pkg/api/oas_operations_gen.go @@ -38,6 +38,7 @@ const ( DeleteScriptOperation OperationName = "DeleteScript" DeleteScriptPolicyOperation OperationName = "DeleteScriptPolicy" GetMapOperation OperationName = "GetMap" + GetMapAssetLocationOperation OperationName = "GetMapAssetLocation" GetMapfixOperation OperationName = "GetMapfix" GetOperationOperation OperationName = "GetOperation" GetScriptOperation OperationName = "GetScript" diff --git a/pkg/api/oas_parameters_gen.go b/pkg/api/oas_parameters_gen.go index df865e0..c5c4fed 100644 --- a/pkg/api/oas_parameters_gen.go +++ b/pkg/api/oas_parameters_gen.go @@ -2256,6 +2256,88 @@ func decodeGetMapParams(args [1]string, argsEscaped bool, r *http.Request) (para return params, nil } +// GetMapAssetLocationParams is parameters of getMapAssetLocation operation. +type GetMapAssetLocationParams struct { + MapID int64 +} + +func unpackGetMapAssetLocationParams(packed middleware.Parameters) (params GetMapAssetLocationParams) { + { + key := middleware.ParameterKey{ + Name: "MapID", + In: "path", + } + params.MapID = packed[key].(int64) + } + return params +} + +func decodeGetMapAssetLocationParams(args [1]string, argsEscaped bool, r *http.Request) (params GetMapAssetLocationParams, _ error) { + // Decode path: MapID. + 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: "MapID", + 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.MapID = 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.MapID)); 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: "MapID", + In: "path", + Err: err, + } + } + return params, nil +} + // GetMapfixParams is parameters of getMapfix operation. type GetMapfixParams struct { // The unique identifier for a mapfix. diff --git a/pkg/api/oas_response_decoders_gen.go b/pkg/api/oas_response_decoders_gen.go index 4270eb1..1f8d947 100644 --- a/pkg/api/oas_response_decoders_gen.go +++ b/pkg/api/oas_response_decoders_gen.go @@ -3,6 +3,7 @@ package api import ( + "bytes" "fmt" "io" "mime" @@ -2181,6 +2182,82 @@ func decodeGetMapResponse(resp *http.Response) (res *Map, _ error) { return res, errors.Wrap(defRes, "error") } +func decodeGetMapAssetLocationResponse(resp *http.Response) (res GetMapAssetLocationOK, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "text/plain": + reader := resp.Body + b, err := io.ReadAll(reader) + if err != nil { + return res, err + } + + response := GetMapAssetLocationOK{Data: bytes.NewReader(b)} + return response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *ErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &ErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + func decodeGetMapfixResponse(resp *http.Response) (res *Mapfix, _ error) { switch resp.StatusCode { case 200: diff --git a/pkg/api/oas_response_encoders_gen.go b/pkg/api/oas_response_encoders_gen.go index 6d277e7..8d0f049 100644 --- a/pkg/api/oas_response_encoders_gen.go +++ b/pkg/api/oas_response_encoders_gen.go @@ -3,6 +3,7 @@ package api import ( + "io" "net/http" "github.com/go-faster/errors" @@ -279,6 +280,22 @@ func encodeGetMapResponse(response *Map, w http.ResponseWriter, span trace.Span) return nil } +func encodeGetMapAssetLocationResponse(response GetMapAssetLocationOK, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + writer := w + if closer, ok := response.Data.(io.Closer); ok { + defer closer.Close() + } + if _, err := io.Copy(writer, response); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + func encodeGetMapfixResponse(response *Mapfix, w http.ResponseWriter, span trace.Span) error { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) diff --git a/pkg/api/oas_router_gen.go b/pkg/api/oas_router_gen.go index f92ea12..feb2cfc 100644 --- a/pkg/api/oas_router_gen.go +++ b/pkg/api/oas_router_gen.go @@ -571,16 +571,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Param: "MapID" - // Leaf parameter, slashes are prohibited + // Match until "/" idx := strings.IndexByte(elem, '/') - if idx >= 0 { - break + if idx < 0 { + idx = len(elem) } - args[0] = elem - elem = "" + args[0] = elem[:idx] + elem = elem[idx:] if len(elem) == 0 { - // Leaf node. switch r.Method { case "GET": s.handleGetMapRequest([1]string{ @@ -592,6 +591,30 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + switch elem[0] { + case '/': // Prefix: "/location" + + if l := len("/location"); len(elem) >= l && elem[0:l] == "/location" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "GET": + s.handleGetMapAssetLocationRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, "GET") + } + + return + } + + } } @@ -2014,16 +2037,15 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { } // Param: "MapID" - // Leaf parameter, slashes are prohibited + // Match until "/" idx := strings.IndexByte(elem, '/') - if idx >= 0 { - break + if idx < 0 { + idx = len(elem) } - args[0] = elem - elem = "" + args[0] = elem[:idx] + elem = elem[idx:] if len(elem) == 0 { - // Leaf node. switch method { case "GET": r.name = GetMapOperation @@ -2037,6 +2059,32 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { return } } + switch elem[0] { + case '/': // Prefix: "/location" + + if l := len("/location"); len(elem) >= l && elem[0:l] == "/location" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "GET": + r.name = GetMapAssetLocationOperation + r.summary = "Get location of asset" + r.operationID = "getMapAssetLocation" + r.pathPattern = "/maps/{MapID}/location" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + + } } diff --git a/pkg/api/oas_schemas_gen.go b/pkg/api/oas_schemas_gen.go index 3d1a5a7..7646938 100644 --- a/pkg/api/oas_schemas_gen.go +++ b/pkg/api/oas_schemas_gen.go @@ -304,6 +304,20 @@ func (s *ErrorStatusCode) SetResponse(val Error) { s.Response = val } +type GetMapAssetLocationOK struct { + Data io.Reader +} + +// Read reads data from the Data reader. +// +// Kept to satisfy the io.Reader interface. +func (s GetMapAssetLocationOK) Read(p []byte) (n int, err error) { + if s.Data == nil { + return 0, io.EOF + } + return s.Data.Read(p) +} + // Ref: #/components/schemas/Map type Map struct { ID int64 `json:"ID"` diff --git a/pkg/api/oas_security_gen.go b/pkg/api/oas_security_gen.go index a9b56b8..d2a0919 100644 --- a/pkg/api/oas_security_gen.go +++ b/pkg/api/oas_security_gen.go @@ -65,6 +65,7 @@ var operationRolesCookieAuth = map[string][]string{ CreateSubmissionAuditCommentOperation: []string{}, DeleteScriptOperation: []string{}, DeleteScriptPolicyOperation: []string{}, + GetMapAssetLocationOperation: []string{}, GetOperationOperation: []string{}, ReleaseSubmissionsOperation: []string{}, SessionRolesOperation: []string{}, diff --git a/pkg/api/oas_server_gen.go b/pkg/api/oas_server_gen.go index 1e6d4a9..dfe14e3 100644 --- a/pkg/api/oas_server_gen.go +++ b/pkg/api/oas_server_gen.go @@ -202,6 +202,12 @@ type Handler interface { // // GET /maps/{MapID} GetMap(ctx context.Context, params GetMapParams) (*Map, error) + // GetMapAssetLocation implements getMapAssetLocation operation. + // + // Get location of asset. + // + // GET /maps/{MapID}/location + GetMapAssetLocation(ctx context.Context, params GetMapAssetLocationParams) (GetMapAssetLocationOK, error) // GetMapfix implements getMapfix operation. // // Retrieve map with ID. diff --git a/pkg/api/oas_unimplemented_gen.go b/pkg/api/oas_unimplemented_gen.go index c1b8fdb..2cba41c 100644 --- a/pkg/api/oas_unimplemented_gen.go +++ b/pkg/api/oas_unimplemented_gen.go @@ -303,6 +303,15 @@ func (UnimplementedHandler) GetMap(ctx context.Context, params GetMapParams) (r return r, ht.ErrNotImplemented } +// GetMapAssetLocation implements getMapAssetLocation operation. +// +// Get location of asset. +// +// GET /maps/{MapID}/location +func (UnimplementedHandler) GetMapAssetLocation(ctx context.Context, params GetMapAssetLocationParams) (r GetMapAssetLocationOK, _ error) { + return r, ht.ErrNotImplemented +} + // GetMapfix implements getMapfix operation. // // Retrieve map with ID. diff --git a/pkg/cmds/serve.go b/pkg/cmds/serve.go index 0309c9e..39391c3 100644 --- a/pkg/cmds/serve.go +++ b/pkg/cmds/serve.go @@ -10,6 +10,7 @@ import ( "git.itzana.me/strafesnet/maps-service/pkg/api" "git.itzana.me/strafesnet/maps-service/pkg/datastore/gormstore" internal "git.itzana.me/strafesnet/maps-service/pkg/internal" + "git.itzana.me/strafesnet/maps-service/pkg/roblox" "git.itzana.me/strafesnet/maps-service/pkg/service" "git.itzana.me/strafesnet/maps-service/pkg/service_internal" "github.com/nats-io/nats.go" @@ -91,6 +92,12 @@ func NewServeCommand() *cli.Command { EnvVars: []string{"NATS_HOST"}, Value: "nats:4222", }, + &cli.StringFlag{ + Name: "rbx-api-key", + Usage: "API Key for downloading asset locations", + EnvVars: []string{"RBX_API_KEY"}, + Required: true, + }, }, } } @@ -128,6 +135,10 @@ func serve(ctx *cli.Context) error { Nats: js, Maps: maps.NewMapsServiceClient(conn), Users: users.NewUsersServiceClient(conn), + Roblox: roblox.Client{ + HttpClient: http.DefaultClient, + ApiKey: ctx.String("rbx-api-key"), + }, } conn, err = grpc.Dial(ctx.String("auth-rpc-host"), grpc.WithTransportCredentials(insecure.NewCredentials())) diff --git a/pkg/roblox/asset_location.go b/pkg/roblox/asset_location.go new file mode 100644 index 0000000..d75ecc1 --- /dev/null +++ b/pkg/roblox/asset_location.go @@ -0,0 +1,72 @@ +package roblox + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "io" +) + +// Struct equivalent to Rust's AssetLocationInfo +type AssetLocationInfo struct { + Location string `json:"location"` + RequestId string `json:"requestId"` + IsHashDynamic bool `json:"IsHashDynamic"` + IsCopyrightProtected bool `json:"IsCopyrightProtected"` + IsArchived bool `json:"isArchived"` + AssetTypeId uint32 `json:"assetTypeId"` +} + +// Input struct for getAssetLocation +type GetAssetLatestRequest struct { + AssetID uint64 +} + +// Custom error type if needed +type GetError string + +func (e GetError) Error() string { return string(e) } + +// Example client with a Get method +type Client struct { + HttpClient *http.Client + ApiKey string +} + +func (c *Client) GetAssetLocation(config GetAssetLatestRequest) (*AssetLocationInfo, error) { + rawURL := fmt.Sprintf("https://apis.roblox.com/asset-delivery-api/v1/assetId/%d", config.AssetID) + parsedURL, err := url.Parse(rawURL) + if err != nil { + return nil, GetError("ParseError: " + err.Error()) + } + + req, err := http.NewRequest("GET", parsedURL.String(), nil) + if err != nil { + return nil, GetError("RequestCreationError: " + err.Error()) + } + + req.Header.Set("x-api-key", c.ApiKey) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, GetError("ReqwestError: " + err.Error()) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, GetError(fmt.Sprintf("ResponseError: status code %d", resp.StatusCode)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, GetError("ReadBodyError: " + err.Error()) + } + + var info AssetLocationInfo + if err := json.Unmarshal(body, &info); err != nil { + return nil, GetError("JSONError: " + err.Error()) + } + + return &info, nil +} diff --git a/pkg/service/maps.go b/pkg/service/maps.go index aa7b52f..40ea3de 100644 --- a/pkg/service/maps.go +++ b/pkg/service/maps.go @@ -2,9 +2,11 @@ package service import ( "context" + "strings" "git.itzana.me/strafesnet/go-grpc/maps" "git.itzana.me/strafesnet/maps-service/pkg/api" + "git.itzana.me/strafesnet/maps-service/pkg/roblox" ) // ListMaps implements listMaps operation. @@ -71,3 +73,29 @@ func (svc *Service) GetMap(ctx context.Context, params api.GetMapParams) (*api.M Date: mapResponse.Date, }, nil } + +// GetMapAssetLocation invokes getMapAssetLocation operation. +// +// Get location of map asset. +// +// GET /maps/{MapID}/location +func (svc *Service) GetMapAssetLocation(ctx context.Context, params api.GetMapAssetLocationParams) (ok api.GetMapAssetLocationOK, err error) { + // Ensure map exists in the db! + // This could otherwise be used to access any asset + _, err = svc.Maps.Get(ctx, &maps.IdMessage{ + ID: params.MapID, + }) + if err != nil { + return ok, err + } + + info, err := svc.Roblox.GetAssetLocation(roblox.GetAssetLatestRequest{ + AssetID: uint64(params.MapID), + }) + if err != nil{ + return ok, err + } + + ok.Data = strings.NewReader(info.Location) + return ok, nil +} diff --git a/pkg/service/service.go b/pkg/service/service.go index 25f9215..db5ccd1 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -9,6 +9,7 @@ import ( "git.itzana.me/strafesnet/go-grpc/users" "git.itzana.me/strafesnet/maps-service/pkg/api" "git.itzana.me/strafesnet/maps-service/pkg/datastore" + "git.itzana.me/strafesnet/maps-service/pkg/roblox" "github.com/nats-io/nats.go" ) @@ -35,6 +36,7 @@ type Service struct { Nats nats.JetStreamContext Maps maps.MapsServiceClient Users users.UsersServiceClient + Roblox roblox.Client } // NewError creates *ErrorStatusCode from error returned by handler. diff --git a/web/src/app/_components/downloadButton.tsx b/web/src/app/_components/downloadButton.tsx new file mode 100644 index 0000000..f144aaf --- /dev/null +++ b/web/src/app/_components/downloadButton.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import { Button } from '@mui/material'; +import Download from '@mui/icons-material/Download'; + +interface DownloadButtonProps { + assetId: number; + assetName: string; +} + +const DownloadButton: React.FC = ({ assetId, assetName }) => { + const [downloading, setDownloading] = useState(false); + + const handleDownload = async () => { + setDownloading(true); + try { + // Fetch the download URL + const res = await fetch(`/api/maps/${assetId}/location`); + if (!res.ok) throw new Error('Failed to fetch download location'); + + const location = await res.text(); + + // Method 1: Try direct download with proper cleanup + try { + const link = document.createElement('a'); + link.href = location.trim(); // Remove any whitespace + link.download = `${assetName}.rbxm`; + link.target = '_blank'; // Open in new tab as fallback + link.rel = 'noopener noreferrer'; // Security best practice + + // Ensure the link is properly attached before clicking + document.body.appendChild(link); + link.click(); + + // Clean up after a short delay to ensure download starts + setTimeout(() => { + document.body.removeChild(link); + }, 100); + + } catch (domError) { + console.warn('Direct download failed, trying fallback:', domError); + // Method 2: Fallback - open in new window + window.open(location.trim(), '_blank'); + } + + } catch (err) { + console.error('Download error:', err); + // Optional: Show user-friendly error message + alert('Download failed. Please try again.'); + } finally { + setDownloading(false); + } + }; + + return ( + + ); +}; + +export default DownloadButton; diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index 7892efd..cd8da52 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -30,6 +30,8 @@ import PersonIcon from "@mui/icons-material/Person"; import FlagIcon from "@mui/icons-material/Flag"; import BugReportIcon from "@mui/icons-material/BugReport"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import DownloadButton from "@/app/_components/downloadButton"; +import { hasRole, RolesConstants } from "@/app/ts/Roles"; export default function MapDetails() { const { mapId } = useParams(); @@ -38,6 +40,7 @@ export default function MapDetails() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [copySuccess, setCopySuccess] = useState(false); + const [roles, setRoles] = useState(RolesConstants.Empty); useEffect(() => { async function getMap() { @@ -60,6 +63,25 @@ export default function MapDetails() { getMap(); }, [mapId]); + useEffect(() => { + async function getRoles() { + try { + const rolesResponse = await fetch("/api/session/roles"); + if (rolesResponse.ok) { + const rolesData = await rolesResponse.json(); + setRoles(rolesData.Roles); + } else { + console.warn(`Failed to fetch roles: ${rolesResponse.status}`); + setRoles(RolesConstants.Empty); + } + } catch (error) { + console.warn("Error fetching roles data:", error); + setRoles(RolesConstants.Empty); + } + } + getRoles() + }, [mapId]); + const formatDate = (timestamp: number) => { return new Date(timestamp * 1000).toLocaleDateString('en-US', { year: 'numeric', @@ -315,6 +337,9 @@ export default function MapDetails() { > Submit a Mapfix + {hasRole(roles,RolesConstants.MapDownload) && ( + + )} @@ -335,4 +360,4 @@ export default function MapDetails() { ); -} \ No newline at end of file +} -- 2.49.1 From 976adf2b664dc51287bf3615325e917d8269c70f Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 24 Jun 2025 06:05:50 +0000 Subject: [PATCH 6/7] Move Download Button Below Title (#206) Senior itzaname envisioned the button existing elsewhere. Reviewed-on: https://git.itzana.me/StrafesNET/maps-service/pulls/206 Co-authored-by: Quaternions Co-committed-by: Quaternions --- web/src/app/_components/downloadButton.tsx | 69 ---------------------- web/src/app/maps/[mapId]/page.tsx | 67 +++++++++++++++++++-- 2 files changed, 63 insertions(+), 73 deletions(-) delete mode 100644 web/src/app/_components/downloadButton.tsx diff --git a/web/src/app/_components/downloadButton.tsx b/web/src/app/_components/downloadButton.tsx deleted file mode 100644 index f144aaf..0000000 --- a/web/src/app/_components/downloadButton.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useState } from 'react'; -import { Button } from '@mui/material'; -import Download from '@mui/icons-material/Download'; - -interface DownloadButtonProps { - assetId: number; - assetName: string; -} - -const DownloadButton: React.FC = ({ assetId, assetName }) => { - const [downloading, setDownloading] = useState(false); - - const handleDownload = async () => { - setDownloading(true); - try { - // Fetch the download URL - const res = await fetch(`/api/maps/${assetId}/location`); - if (!res.ok) throw new Error('Failed to fetch download location'); - - const location = await res.text(); - - // Method 1: Try direct download with proper cleanup - try { - const link = document.createElement('a'); - link.href = location.trim(); // Remove any whitespace - link.download = `${assetName}.rbxm`; - link.target = '_blank'; // Open in new tab as fallback - link.rel = 'noopener noreferrer'; // Security best practice - - // Ensure the link is properly attached before clicking - document.body.appendChild(link); - link.click(); - - // Clean up after a short delay to ensure download starts - setTimeout(() => { - document.body.removeChild(link); - }, 100); - - } catch (domError) { - console.warn('Direct download failed, trying fallback:', domError); - // Method 2: Fallback - open in new window - window.open(location.trim(), '_blank'); - } - - } catch (err) { - console.error('Download error:', err); - // Optional: Show user-friendly error message - alert('Download failed. Please try again.'); - } finally { - setDownloading(false); - } - }; - - return ( - - ); -}; - -export default DownloadButton; diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index cd8da52..c1d9b75 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -30,7 +30,8 @@ import PersonIcon from "@mui/icons-material/Person"; import FlagIcon from "@mui/icons-material/Flag"; import BugReportIcon from "@mui/icons-material/BugReport"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -import DownloadButton from "@/app/_components/downloadButton"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import DownloadIcon from '@mui/icons-material/Download'; import { hasRole, RolesConstants } from "@/app/ts/Roles"; export default function MapDetails() { @@ -41,6 +42,7 @@ export default function MapDetails() { const [error, setError] = useState(null); const [copySuccess, setCopySuccess] = useState(false); const [roles, setRoles] = useState(RolesConstants.Empty); + const [downloading, setDownloading] = useState(false); useEffect(() => { async function getMap() { @@ -124,6 +126,48 @@ export default function MapDetails() { setCopySuccess(true); }; + + const handleDownload = async () => { + setDownloading(true); + try { + // Fetch the download URL + const res = await fetch(`/api/maps/${mapId}/location`); + if (!res.ok) throw new Error('Failed to fetch download location'); + + const location = await res.text(); + + // Method 1: Try direct download with proper cleanup + try { + const link = document.createElement('a'); + link.href = location.trim(); // Remove any whitespace + link.download = `${map?.DisplayName}.rbxm`; + link.target = '_blank'; // Open in new tab as fallback + link.rel = 'noopener noreferrer'; // Security best practice + + // Ensure the link is properly attached before clicking + document.body.appendChild(link); + link.click(); + + // Clean up after a short delay to ensure download starts + setTimeout(() => { + document.body.removeChild(link); + }, 100); + + } catch (domError) { + console.warn('Direct download failed, trying fallback:', domError); + // Method 2: Fallback - open in new window + window.open(location.trim(), '_blank'); + } + + } catch (err) { + console.error('Download error:', err); + // Optional: Show user-friendly error message + alert('Download failed. Please try again.'); + } finally { + setDownloading(false); + } + }; + const handleCloseSnackbar = () => { setCopySuccess(false); }; @@ -255,6 +299,24 @@ export default function MapDetails() { + {!loading && hasRole(roles,RolesConstants.MapDownload) && ( + + + + Download + + + + + + + + )} @@ -337,9 +399,6 @@ export default function MapDetails() { > Submit a Mapfix - {hasRole(roles,RolesConstants.MapDownload) && ( - - )} -- 2.49.1 From 40b0af00633c5d759c6038b4d8242bb503985352 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Mon, 23 Jun 2025 23:34:58 -0700 Subject: [PATCH 7/7] Revert "Validation: Make Assets Loadable on Maptest (#198)" This reverts commit abd233ce65a6d6f4d4ecec0df77fdddae0f8ae4d. --- Cargo.lock | 4 ++-- validation/Cargo.toml | 2 +- validation/src/create.rs | 17 +---------------- validation/src/validator.rs | 9 --------- 4 files changed, 4 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 49fc146..4de520c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1379,9 +1379,9 @@ dependencies = [ [[package]] name = "rbx_asset" -version = "0.4.6" +version = "0.4.5" source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/" -checksum = "860909d8375a54deb2a50187b1b792dcf88c0d2e21c18f0c1d44b34e2f027f36" +checksum = "b448bf22f70748215c2a937158f83790bf3f4df81e2af8521a089bc821155360" dependencies = [ "bytes", "chrono", diff --git a/validation/Cargo.toml b/validation/Cargo.toml index 3bc4c13..06c3a83 100644 --- a/validation/Cargo.toml +++ b/validation/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" submissions-api = { path = "api", features = ["internal"], default-features = false, registry = "strafesnet" } async-nats = "0.41.0" futures = "0.3.31" -rbx_asset = { version = "0.4.6", registry = "strafesnet" } +rbx_asset = { version = "0.4.5", registry = "strafesnet" } rbx_binary = "1.0.0" rbx_dom_weak = "3.0.0" rbx_reflection_database = "1.0.3" diff --git a/validation/src/create.rs b/validation/src/create.rs index 3a14afe..f29d4ca 100644 --- a/validation/src/create.rs +++ b/validation/src/create.rs @@ -9,8 +9,6 @@ pub enum Error{ Download(crate::download::Error), ModelFileDecode(ReadDomError), GetRootInstance(GetRootInstanceError), - InvalidGamePrefix, - LoadableOnMaptest(rbx_asset::cookie::SetAssetsPermissionsError), } impl std::fmt::Display for Error{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ @@ -64,24 +62,11 @@ impl crate::message_handler::MessageHandler{ game_id, }=get_mapinfo(&dom,model_instance); - let game_id=game_id.map_err(|_|Error::InvalidGamePrefix)?; - - let universe_id=match &game_id{ - GameID::Bhop=>4422715291, - GameID::Surf=>4422716026, - GameID::FlyTrials=>4419912257, - }; - let config=rbx_asset::cookie::SetAssetsPermissionsRequest{ - universe_id, - asset_ids:&[create_info.ModelID], - }; - self.cookie_context.set_assets_permissions(config).await.map_err(Error::LoadableOnMaptest)?; - Ok(CreateResult{ AssetOwner:user_id, DisplayName:display_name.ok().map(ToOwned::to_owned), Creator:creator.ok().map(ToOwned::to_owned), - GameID:Some(game_id), + GameID:game_id.ok(), AssetVersion:asset_version, }) } diff --git a/validation/src/validator.rs b/validation/src/validator.rs index 0945528..39ffa0c 100644 --- a/validation/src/validator.rs +++ b/validation/src/validator.rs @@ -51,7 +51,6 @@ pub enum Error{ ApiGetScriptFromHash(submissions_api::types::ScriptSingleItemError), ApiUpdateMapfixModel(submissions_api::Error), ApiUpdateSubmissionModel(submissions_api::Error), - LoadableOnMaptest(rbx_asset::cookie::SetAssetsPermissionsError), ModelFileRootMustHaveOneChild, ModelFileChildRefIsNil, ModelFileEncode(rbx_binary::EncodeError), @@ -297,14 +296,6 @@ impl crate::message_handler::MessageHandler{ }, } - // Map Staging - let universe_id=7895115682; - let config=rbx_asset::cookie::SetAssetsPermissionsRequest{ - universe_id, - asset_ids:&[validated_model_id], - }; - self.cookie_context.set_assets_permissions(config).await.map_err(Error::LoadableOnMaptest)?; - Ok(()) } } -- 2.49.1