From 40c2ccad15fd00e14d0e5e1c05f055358d4d69aa Mon Sep 17 00:00:00 2001 From: Quaternions Date: Fri, 13 Jun 2025 21:00:21 -0700 Subject: [PATCH 01/19] submissions: asset location api --- pkg/roblox/asset_location.go | 72 ++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 pkg/roblox/asset_location.go 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 +} -- 2.49.1 From cf39ac5b610f1899f12e53e80efdede3ae071a83 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Fri, 13 Jun 2025 21:08:33 -0700 Subject: [PATCH 02/19] openapi: asset location endpoint --- openapi.yaml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index f3008a5..c6162ce 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6,6 +6,8 @@ info: servers: - url: https://submissions.strafes.net/v1 tags: + - name: Assets + description: Asset operations - name: Mapfixes description: Mapfix operations - name: Maps @@ -80,6 +82,34 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /asset/{AssetID}/location: + get: + summary: Get location of asset + operationId: assetLocation + tags: + - Assets + parameters: + - name: AssetID + 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" /maps: get: summary: Get list of maps -- 2.49.1 From cf7d11c80ae388bc5fc95fc955e9da55ef36f331 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Fri, 13 Jun 2025 21:19:58 -0700 Subject: [PATCH 03/19] openapi: generate --- 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 | 92 +++++++++++++ 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 ++ 11 files changed, 624 insertions(+) diff --git a/pkg/api/oas_client_gen.go b/pkg/api/oas_client_gen.go index 98464be..213d300 100644 --- a/pkg/api/oas_client_gen.go +++ b/pkg/api/oas_client_gen.go @@ -163,6 +163,12 @@ type Invoker interface { // // POST /submissions/{SubmissionID}/status/reset-uploading ActionSubmissionValidated(ctx context.Context, params ActionSubmissionValidatedParams) error + // AssetLocation invokes assetLocation operation. + // + // Get location of asset. + // + // GET /asset/{AssetID}/location + AssetLocation(ctx context.Context, params AssetLocationParams) (AssetLocationOK, error) // CreateMapfix invokes createMapfix operation. // // Trigger the validator to create a mapfix. @@ -3136,6 +3142,130 @@ func (c *Client) sendActionSubmissionValidated(ctx context.Context, params Actio return result, nil } +// AssetLocation invokes assetLocation operation. +// +// Get location of asset. +// +// GET /asset/{AssetID}/location +func (c *Client) AssetLocation(ctx context.Context, params AssetLocationParams) (AssetLocationOK, error) { + res, err := c.sendAssetLocation(ctx, params) + return res, err +} + +func (c *Client) sendAssetLocation(ctx context.Context, params AssetLocationParams) (res AssetLocationOK, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("assetLocation"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/asset/{AssetID}/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, AssetLocationOperation, + 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] = "/asset/" + { + // Encode "AssetID" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "AssetID", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.Int64ToString(params.AssetID)) + }(); 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, AssetLocationOperation, 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 := decodeAssetLocationResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + // CreateMapfix invokes createMapfix operation. // // Trigger the validator to create a mapfix. diff --git a/pkg/api/oas_handlers_gen.go b/pkg/api/oas_handlers_gen.go index aad4887..32e530f 100644 --- a/pkg/api/oas_handlers_gen.go +++ b/pkg/api/oas_handlers_gen.go @@ -4322,6 +4322,201 @@ func (s *Server) handleActionSubmissionValidatedRequest(args [1]string, argsEsca } } +// handleAssetLocationRequest handles assetLocation operation. +// +// Get location of asset. +// +// GET /asset/{AssetID}/location +func (s *Server) handleAssetLocationRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("assetLocation"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/asset/{AssetID}/location"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), AssetLocationOperation, + 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: AssetLocationOperation, + ID: "assetLocation", + } + ) + { + type bitset = [1]uint8 + var satisfied bitset + { + sctx, ok, err := s.securityCookieAuth(ctx, AssetLocationOperation, 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 := decodeAssetLocationParams(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 AssetLocationOK + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: AssetLocationOperation, + OperationSummary: "Get location of asset", + OperationID: "assetLocation", + Body: nil, + Params: middleware.Parameters{ + { + Name: "AssetID", + In: "path", + }: params.AssetID, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = AssetLocationParams + Response = AssetLocationOK + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackAssetLocationParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.AssetLocation(ctx, params) + return response, err + }, + ) + } else { + response, err = s.h.AssetLocation(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 := encodeAssetLocationResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + // handleCreateMapfixRequest handles createMapfix operation. // // Trigger the validator to create a mapfix. diff --git a/pkg/api/oas_operations_gen.go b/pkg/api/oas_operations_gen.go index 80ed8f5..67635ec 100644 --- a/pkg/api/oas_operations_gen.go +++ b/pkg/api/oas_operations_gen.go @@ -28,6 +28,7 @@ const ( ActionSubmissionTriggerUploadOperation OperationName = "ActionSubmissionTriggerUpload" ActionSubmissionTriggerValidateOperation OperationName = "ActionSubmissionTriggerValidate" ActionSubmissionValidatedOperation OperationName = "ActionSubmissionValidated" + AssetLocationOperation OperationName = "AssetLocation" CreateMapfixOperation OperationName = "CreateMapfix" CreateMapfixAuditCommentOperation OperationName = "CreateMapfixAuditComment" CreateScriptOperation OperationName = "CreateScript" diff --git a/pkg/api/oas_parameters_gen.go b/pkg/api/oas_parameters_gen.go index df865e0..6d1f60c 100644 --- a/pkg/api/oas_parameters_gen.go +++ b/pkg/api/oas_parameters_gen.go @@ -1841,6 +1841,88 @@ func decodeActionSubmissionValidatedParams(args [1]string, argsEscaped bool, r * return params, nil } +// AssetLocationParams is parameters of assetLocation operation. +type AssetLocationParams struct { + AssetID int64 +} + +func unpackAssetLocationParams(packed middleware.Parameters) (params AssetLocationParams) { + { + key := middleware.ParameterKey{ + Name: "AssetID", + In: "path", + } + params.AssetID = packed[key].(int64) + } + return params +} + +func decodeAssetLocationParams(args [1]string, argsEscaped bool, r *http.Request) (params AssetLocationParams, _ error) { + // Decode path: AssetID. + 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: "AssetID", + 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.AssetID = 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.AssetID)); 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: "AssetID", + In: "path", + Err: err, + } + } + return params, nil +} + // CreateMapfixAuditCommentParams is parameters of createMapfixAuditComment operation. type CreateMapfixAuditCommentParams 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..51715ed 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" @@ -1335,6 +1336,82 @@ func decodeActionSubmissionValidatedResponse(resp *http.Response) (res *ActionSu return res, errors.Wrap(defRes, "error") } +func decodeAssetLocationResponse(resp *http.Response) (res AssetLocationOK, _ 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 := AssetLocationOK{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 decodeCreateMapfixResponse(resp *http.Response) (res *OperationID, _ error) { switch resp.StatusCode { case 201: diff --git a/pkg/api/oas_response_encoders_gen.go b/pkg/api/oas_response_encoders_gen.go index 6d277e7..5766d2c 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" @@ -167,6 +168,22 @@ func encodeActionSubmissionValidatedResponse(response *ActionSubmissionValidated return nil } +func encodeAssetLocationResponse(response AssetLocationOK, 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 encodeCreateMapfixResponse(response *OperationID, w http.ResponseWriter, span trace.Span) error { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(201) diff --git a/pkg/api/oas_router_gen.go b/pkg/api/oas_router_gen.go index f92ea12..7310ff7 100644 --- a/pkg/api/oas_router_gen.go +++ b/pkg/api/oas_router_gen.go @@ -61,6 +61,51 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { break } switch elem[0] { + case 'a': // Prefix: "asset/" + + if l := len("asset/"); len(elem) >= l && elem[0:l] == "asset/" { + elem = elem[l:] + } else { + break + } + + // Param: "AssetID" + // Match until "/" + idx := strings.IndexByte(elem, '/') + if idx < 0 { + idx = len(elem) + } + args[0] = elem[:idx] + elem = elem[idx:] + + if len(elem) == 0 { + break + } + 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.handleAssetLocationRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, "GET") + } + + return + } + + } + case 'm': // Prefix: "map" if l := len("map"); len(elem) >= l && elem[0:l] == "map" { @@ -1458,6 +1503,53 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { break } switch elem[0] { + case 'a': // Prefix: "asset/" + + if l := len("asset/"); len(elem) >= l && elem[0:l] == "asset/" { + elem = elem[l:] + } else { + break + } + + // Param: "AssetID" + // Match until "/" + idx := strings.IndexByte(elem, '/') + if idx < 0 { + idx = len(elem) + } + args[0] = elem[:idx] + elem = elem[idx:] + + if len(elem) == 0 { + break + } + 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 = AssetLocationOperation + r.summary = "Get location of asset" + r.operationID = "assetLocation" + r.pathPattern = "/asset/{AssetID}/location" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + + } + case 'm': // Prefix: "map" if l := len("map"); len(elem) >= l && elem[0:l] == "map" { diff --git a/pkg/api/oas_schemas_gen.go b/pkg/api/oas_schemas_gen.go index 3d1a5a7..a0a86ad 100644 --- a/pkg/api/oas_schemas_gen.go +++ b/pkg/api/oas_schemas_gen.go @@ -80,6 +80,20 @@ type ActionSubmissionTriggerValidateNoContent struct{} // ActionSubmissionValidatedNoContent is response for ActionSubmissionValidated operation. type ActionSubmissionValidatedNoContent struct{} +type AssetLocationOK struct { + Data io.Reader +} + +// Read reads data from the Data reader. +// +// Kept to satisfy the io.Reader interface. +func (s AssetLocationOK) Read(p []byte) (n int, err error) { + if s.Data == nil { + return 0, io.EOF + } + return s.Data.Read(p) +} + // Ref: #/components/schemas/AuditEvent type AuditEvent struct { ID int64 `json:"ID"` diff --git a/pkg/api/oas_security_gen.go b/pkg/api/oas_security_gen.go index a9b56b8..a82b868 100644 --- a/pkg/api/oas_security_gen.go +++ b/pkg/api/oas_security_gen.go @@ -56,6 +56,7 @@ var operationRolesCookieAuth = map[string][]string{ ActionSubmissionTriggerUploadOperation: []string{}, ActionSubmissionTriggerValidateOperation: []string{}, ActionSubmissionValidatedOperation: []string{}, + AssetLocationOperation: []string{}, CreateMapfixOperation: []string{}, CreateMapfixAuditCommentOperation: []string{}, CreateScriptOperation: []string{}, diff --git a/pkg/api/oas_server_gen.go b/pkg/api/oas_server_gen.go index 1e6d4a9..426dad6 100644 --- a/pkg/api/oas_server_gen.go +++ b/pkg/api/oas_server_gen.go @@ -142,6 +142,12 @@ type Handler interface { // // POST /submissions/{SubmissionID}/status/reset-uploading ActionSubmissionValidated(ctx context.Context, params ActionSubmissionValidatedParams) error + // AssetLocation implements assetLocation operation. + // + // Get location of asset. + // + // GET /asset/{AssetID}/location + AssetLocation(ctx context.Context, params AssetLocationParams) (AssetLocationOK, error) // CreateMapfix implements createMapfix operation. // // Trigger the validator to create a mapfix. diff --git a/pkg/api/oas_unimplemented_gen.go b/pkg/api/oas_unimplemented_gen.go index c1b8fdb..3c23f29 100644 --- a/pkg/api/oas_unimplemented_gen.go +++ b/pkg/api/oas_unimplemented_gen.go @@ -213,6 +213,15 @@ func (UnimplementedHandler) ActionSubmissionValidated(ctx context.Context, param return ht.ErrNotImplemented } +// AssetLocation implements assetLocation operation. +// +// Get location of asset. +// +// GET /asset/{AssetID}/location +func (UnimplementedHandler) AssetLocation(ctx context.Context, params AssetLocationParams) (r AssetLocationOK, _ error) { + return r, ht.ErrNotImplemented +} + // CreateMapfix implements createMapfix operation. // // Trigger the validator to create a mapfix. -- 2.49.1 From 5120af820f0354b57b58160b148b47d84e20f148 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Fri, 13 Jun 2025 21:15:16 -0700 Subject: [PATCH 04/19] submissions: add roblox api client --- pkg/cmds/serve.go | 11 +++++++++++ pkg/service/service.go | 2 ++ 2 files changed, 13 insertions(+) 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/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. -- 2.49.1 From 781ab32e5425226e0aa6338d3129fd13ba4d3a00 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Fri, 13 Jun 2025 21:28:31 -0700 Subject: [PATCH 05/19] submissions: asset location endpoint --- pkg/service/asset.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 pkg/service/asset.go diff --git a/pkg/service/asset.go b/pkg/service/asset.go new file mode 100644 index 0000000..3b33719 --- /dev/null +++ b/pkg/service/asset.go @@ -0,0 +1,26 @@ +package service + +import ( + "context" + "strings" + + "git.itzana.me/strafesnet/maps-service/pkg/api" + "git.itzana.me/strafesnet/maps-service/pkg/roblox" +) + +// AssetLocation invokes assetLocation operation. +// +// Get location of asset. +// +// GET /asset/{AssetID}/location +func (svc *Service) AssetLocation(ctx context.Context, params api.AssetLocationParams) (ok api.AssetLocationOK, err error) { + info, err := svc.Roblox.GetAssetLocation(roblox.GetAssetLatestRequest{ + AssetID: uint64(params.AssetID), + }) + if err != nil{ + return ok, err + } + + ok.Data = strings.NewReader(info.Location) + return ok, nil +} -- 2.49.1 From dbc2096c229e3ae4878ed1218cc7cba16eab5d08 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Fri, 13 Jun 2025 21:33:04 -0700 Subject: [PATCH 06/19] web: mock button --- web/src/app/maps/[mapId]/page.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index 7892efd..777b82a 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -315,6 +315,18 @@ export default function MapDetails() { > Submit a Mapfix + { /* Only show this button if user has MapAccess permission */} + { /* onClick should call /asset/{AssetID}/location and then make a href with the location */} + { /* rename the file to something sensible with a .rbxm extension */} + @@ -335,4 +347,4 @@ export default function MapDetails() { ); -} \ No newline at end of file +} -- 2.49.1 From 23c78902df5ba029cc91cb1937af25ba45816f62 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Fri, 13 Jun 2025 21:44:53 -0700 Subject: [PATCH 07/19] web: chat button --- web/src/app/_components/downloadButton.tsx | 49 ++++++++++++++++++++++ web/src/app/maps/[mapId]/page.tsx | 14 +------ 2 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 web/src/app/_components/downloadButton.tsx diff --git a/web/src/app/_components/downloadButton.tsx b/web/src/app/_components/downloadButton.tsx new file mode 100644 index 0000000..9912788 --- /dev/null +++ b/web/src/app/_components/downloadButton.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; +import { Button } from '@mui/material'; +import BugReportIcon from '@mui/icons-material/BugReport'; + +interface DownloadButtonProps { + assetId: number; + assetName: string; // Used for a more readable file name +} + +const DownloadButton: React.FC = ({ assetId, assetName }) => { + const [downloading, setDownloading] = useState(false); + + const handleDownload = async () => { + setDownloading(true); + try { + const res = await fetch(`/asset/${assetId}/location`); + + if (!res.ok) throw new Error('Failed to fetch download location'); + const location = await res.text(); + + const link = document.createElement('a'); + link.href = location; + link.download = `${assetName}.rbxm`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (err) { + console.error('Download error:', err); + } 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 777b82a..547f444 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -30,6 +30,7 @@ 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"; export default function MapDetails() { const { mapId } = useParams(); @@ -315,18 +316,7 @@ export default function MapDetails() { > Submit a Mapfix - { /* Only show this button if user has MapAccess permission */} - { /* onClick should call /asset/{AssetID}/location and then make a href with the location */} - { /* rename the file to something sensible with a .rbxm extension */} - + -- 2.49.1 From c77355cea351a4374c08607f33d6925e9d02d2a2 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Fri, 13 Jun 2025 21:47:18 -0700 Subject: [PATCH 08/19] web: implement roles fetch on maps page --- web/src/app/maps/[mapId]/page.tsx | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index 547f444..cd8da52 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -31,6 +31,7 @@ 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(); @@ -39,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() { @@ -61,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', @@ -316,7 +337,9 @@ export default function MapDetails() { > Submit a Mapfix - + {hasRole(roles,RolesConstants.MapDownload) && ( + + )} -- 2.49.1 From 3d4d7776743ba03d31af1b4791e41bec1b851d87 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Sat, 21 Jun 2025 19:49:16 -0700 Subject: [PATCH 09/19] rename env var --- pkg/cmds/serve.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmds/serve.go b/pkg/cmds/serve.go index 39391c3..034792a 100644 --- a/pkg/cmds/serve.go +++ b/pkg/cmds/serve.go @@ -93,9 +93,9 @@ func NewServeCommand() *cli.Command { Value: "nats:4222", }, &cli.StringFlag{ - Name: "rbx-api-key", + Name: "rbx-api-key-asset-download", Usage: "API Key for downloading asset locations", - EnvVars: []string{"RBX_API_KEY"}, + EnvVars: []string{"RBX_API_KEY_ASSET_DOWNLOAD"}, Required: true, }, }, @@ -137,7 +137,7 @@ func serve(ctx *cli.Context) error { Users: users.NewUsersServiceClient(conn), Roblox: roblox.Client{ HttpClient: http.DefaultClient, - ApiKey: ctx.String("rbx-api-key"), + ApiKey: ctx.String("rbx-api-key-asset-download"), }, } -- 2.49.1 From 04f7b0e0862482cb9dbea12705ab915434972257 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Sat, 21 Jun 2025 21:09:00 -0700 Subject: [PATCH 10/19] openapi: use existing tag instead of inventing a new one --- openapi.yaml | 58 +++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index c6162ce..85ca8e7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6,8 +6,6 @@ info: servers: - url: https://submissions.strafes.net/v1 tags: - - name: Assets - description: Asset operations - name: Mapfixes description: Mapfix operations - name: Maps @@ -82,34 +80,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /asset/{AssetID}/location: - get: - summary: Get location of asset - operationId: assetLocation - tags: - - Assets - parameters: - - name: AssetID - 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" /maps: get: summary: Get list of maps @@ -188,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 -- 2.49.1 From e9ef400b71adc348108d05a54fcbc9c0aab89831 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Sat, 21 Jun 2025 21:23:51 -0700 Subject: [PATCH 11/19] openapi: generate --- pkg/api/oas_client_gen.go | 260 +++++++++--------- pkg/api/oas_handlers_gen.go | 390 +++++++++++++-------------- pkg/api/oas_operations_gen.go | 2 +- pkg/api/oas_parameters_gen.go | 164 +++++------ pkg/api/oas_response_decoders_gen.go | 152 +++++------ pkg/api/oas_response_encoders_gen.go | 32 +-- pkg/api/oas_router_gen.go | 164 +++++------ pkg/api/oas_schemas_gen.go | 28 +- pkg/api/oas_security_gen.go | 2 +- pkg/api/oas_server_gen.go | 12 +- pkg/api/oas_unimplemented_gen.go | 18 +- 11 files changed, 590 insertions(+), 634 deletions(-) diff --git a/pkg/api/oas_client_gen.go b/pkg/api/oas_client_gen.go index 213d300..567b71f 100644 --- a/pkg/api/oas_client_gen.go +++ b/pkg/api/oas_client_gen.go @@ -163,12 +163,6 @@ type Invoker interface { // // POST /submissions/{SubmissionID}/status/reset-uploading ActionSubmissionValidated(ctx context.Context, params ActionSubmissionValidatedParams) error - // AssetLocation invokes assetLocation operation. - // - // Get location of asset. - // - // GET /asset/{AssetID}/location - AssetLocation(ctx context.Context, params AssetLocationParams) (AssetLocationOK, error) // CreateMapfix invokes createMapfix operation. // // Trigger the validator to create a mapfix. @@ -229,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. @@ -3142,130 +3142,6 @@ func (c *Client) sendActionSubmissionValidated(ctx context.Context, params Actio return result, nil } -// AssetLocation invokes assetLocation operation. -// -// Get location of asset. -// -// GET /asset/{AssetID}/location -func (c *Client) AssetLocation(ctx context.Context, params AssetLocationParams) (AssetLocationOK, error) { - res, err := c.sendAssetLocation(ctx, params) - return res, err -} - -func (c *Client) sendAssetLocation(ctx context.Context, params AssetLocationParams) (res AssetLocationOK, err error) { - otelAttrs := []attribute.KeyValue{ - otelogen.OperationID("assetLocation"), - semconv.HTTPRequestMethodKey.String("GET"), - semconv.HTTPRouteKey.String("/asset/{AssetID}/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, AssetLocationOperation, - 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] = "/asset/" - { - // Encode "AssetID" parameter. - e := uri.NewPathEncoder(uri.PathEncoderConfig{ - Param: "AssetID", - Style: uri.PathStyleSimple, - Explode: false, - }) - if err := func() error { - return e.EncodeValue(conv.Int64ToString(params.AssetID)) - }(); 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, AssetLocationOperation, 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 := decodeAssetLocationResponse(resp) - if err != nil { - return res, errors.Wrap(err, "decode response") - } - - return result, nil -} - // CreateMapfix invokes createMapfix operation. // // Trigger the validator to create a mapfix. @@ -4396,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 32e530f..7a36a93 100644 --- a/pkg/api/oas_handlers_gen.go +++ b/pkg/api/oas_handlers_gen.go @@ -4322,201 +4322,6 @@ func (s *Server) handleActionSubmissionValidatedRequest(args [1]string, argsEsca } } -// handleAssetLocationRequest handles assetLocation operation. -// -// Get location of asset. -// -// GET /asset/{AssetID}/location -func (s *Server) handleAssetLocationRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { - statusWriter := &codeRecorder{ResponseWriter: w} - w = statusWriter - otelAttrs := []attribute.KeyValue{ - otelogen.OperationID("assetLocation"), - semconv.HTTPRequestMethodKey.String("GET"), - semconv.HTTPRouteKey.String("/asset/{AssetID}/location"), - } - - // Start a span for this request. - ctx, span := s.cfg.Tracer.Start(r.Context(), AssetLocationOperation, - 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: AssetLocationOperation, - ID: "assetLocation", - } - ) - { - type bitset = [1]uint8 - var satisfied bitset - { - sctx, ok, err := s.securityCookieAuth(ctx, AssetLocationOperation, 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 := decodeAssetLocationParams(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 AssetLocationOK - if m := s.cfg.Middleware; m != nil { - mreq := middleware.Request{ - Context: ctx, - OperationName: AssetLocationOperation, - OperationSummary: "Get location of asset", - OperationID: "assetLocation", - Body: nil, - Params: middleware.Parameters{ - { - Name: "AssetID", - In: "path", - }: params.AssetID, - }, - Raw: r, - } - - type ( - Request = struct{} - Params = AssetLocationParams - Response = AssetLocationOK - ) - response, err = middleware.HookMiddleware[ - Request, - Params, - Response, - ]( - m, - mreq, - unpackAssetLocationParams, - func(ctx context.Context, request Request, params Params) (response Response, err error) { - response, err = s.h.AssetLocation(ctx, params) - return response, err - }, - ) - } else { - response, err = s.h.AssetLocation(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 := encodeAssetLocationResponse(response, w, span); err != nil { - defer recordError("EncodeResponse", err) - if !errors.Is(err, ht.ErrInternalServerErrorResponse) { - s.cfg.ErrorHandler(ctx, w, r, err) - } - return - } -} - // handleCreateMapfixRequest handles createMapfix operation. // // Trigger the validator to create a mapfix. @@ -6451,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 67635ec..e448a29 100644 --- a/pkg/api/oas_operations_gen.go +++ b/pkg/api/oas_operations_gen.go @@ -28,7 +28,6 @@ const ( ActionSubmissionTriggerUploadOperation OperationName = "ActionSubmissionTriggerUpload" ActionSubmissionTriggerValidateOperation OperationName = "ActionSubmissionTriggerValidate" ActionSubmissionValidatedOperation OperationName = "ActionSubmissionValidated" - AssetLocationOperation OperationName = "AssetLocation" CreateMapfixOperation OperationName = "CreateMapfix" CreateMapfixAuditCommentOperation OperationName = "CreateMapfixAuditComment" CreateScriptOperation OperationName = "CreateScript" @@ -39,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 6d1f60c..c5c4fed 100644 --- a/pkg/api/oas_parameters_gen.go +++ b/pkg/api/oas_parameters_gen.go @@ -1841,88 +1841,6 @@ func decodeActionSubmissionValidatedParams(args [1]string, argsEscaped bool, r * return params, nil } -// AssetLocationParams is parameters of assetLocation operation. -type AssetLocationParams struct { - AssetID int64 -} - -func unpackAssetLocationParams(packed middleware.Parameters) (params AssetLocationParams) { - { - key := middleware.ParameterKey{ - Name: "AssetID", - In: "path", - } - params.AssetID = packed[key].(int64) - } - return params -} - -func decodeAssetLocationParams(args [1]string, argsEscaped bool, r *http.Request) (params AssetLocationParams, _ error) { - // Decode path: AssetID. - 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: "AssetID", - 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.AssetID = 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.AssetID)); 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: "AssetID", - In: "path", - Err: err, - } - } - return params, nil -} - // CreateMapfixAuditCommentParams is parameters of createMapfixAuditComment operation. type CreateMapfixAuditCommentParams struct { // The unique identifier for a mapfix. @@ -2338,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 51715ed..1f8d947 100644 --- a/pkg/api/oas_response_decoders_gen.go +++ b/pkg/api/oas_response_decoders_gen.go @@ -1336,82 +1336,6 @@ func decodeActionSubmissionValidatedResponse(resp *http.Response) (res *ActionSu return res, errors.Wrap(defRes, "error") } -func decodeAssetLocationResponse(resp *http.Response) (res AssetLocationOK, _ 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 := AssetLocationOK{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 decodeCreateMapfixResponse(resp *http.Response) (res *OperationID, _ error) { switch resp.StatusCode { case 201: @@ -2258,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 5766d2c..8d0f049 100644 --- a/pkg/api/oas_response_encoders_gen.go +++ b/pkg/api/oas_response_encoders_gen.go @@ -168,22 +168,6 @@ func encodeActionSubmissionValidatedResponse(response *ActionSubmissionValidated return nil } -func encodeAssetLocationResponse(response AssetLocationOK, 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 encodeCreateMapfixResponse(response *OperationID, w http.ResponseWriter, span trace.Span) error { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(201) @@ -296,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 7310ff7..feb2cfc 100644 --- a/pkg/api/oas_router_gen.go +++ b/pkg/api/oas_router_gen.go @@ -61,51 +61,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { break } switch elem[0] { - case 'a': // Prefix: "asset/" - - if l := len("asset/"); len(elem) >= l && elem[0:l] == "asset/" { - elem = elem[l:] - } else { - break - } - - // Param: "AssetID" - // Match until "/" - idx := strings.IndexByte(elem, '/') - if idx < 0 { - idx = len(elem) - } - args[0] = elem[:idx] - elem = elem[idx:] - - if len(elem) == 0 { - break - } - 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.handleAssetLocationRequest([1]string{ - args[0], - }, elemIsEscaped, w, r) - default: - s.notAllowed(w, r, "GET") - } - - return - } - - } - case 'm': // Prefix: "map" if l := len("map"); len(elem) >= l && elem[0:l] == "map" { @@ -616,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{ @@ -637,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 + } + + } } @@ -1503,53 +1481,6 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { break } switch elem[0] { - case 'a': // Prefix: "asset/" - - if l := len("asset/"); len(elem) >= l && elem[0:l] == "asset/" { - elem = elem[l:] - } else { - break - } - - // Param: "AssetID" - // Match until "/" - idx := strings.IndexByte(elem, '/') - if idx < 0 { - idx = len(elem) - } - args[0] = elem[:idx] - elem = elem[idx:] - - if len(elem) == 0 { - break - } - 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 = AssetLocationOperation - r.summary = "Get location of asset" - r.operationID = "assetLocation" - r.pathPattern = "/asset/{AssetID}/location" - r.args = args - r.count = 1 - return r, true - default: - return - } - } - - } - case 'm': // Prefix: "map" if l := len("map"); len(elem) >= l && elem[0:l] == "map" { @@ -2106,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 @@ -2129,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 a0a86ad..7646938 100644 --- a/pkg/api/oas_schemas_gen.go +++ b/pkg/api/oas_schemas_gen.go @@ -80,20 +80,6 @@ type ActionSubmissionTriggerValidateNoContent struct{} // ActionSubmissionValidatedNoContent is response for ActionSubmissionValidated operation. type ActionSubmissionValidatedNoContent struct{} -type AssetLocationOK struct { - Data io.Reader -} - -// Read reads data from the Data reader. -// -// Kept to satisfy the io.Reader interface. -func (s AssetLocationOK) Read(p []byte) (n int, err error) { - if s.Data == nil { - return 0, io.EOF - } - return s.Data.Read(p) -} - // Ref: #/components/schemas/AuditEvent type AuditEvent struct { ID int64 `json:"ID"` @@ -318,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 a82b868..d2a0919 100644 --- a/pkg/api/oas_security_gen.go +++ b/pkg/api/oas_security_gen.go @@ -56,7 +56,6 @@ var operationRolesCookieAuth = map[string][]string{ ActionSubmissionTriggerUploadOperation: []string{}, ActionSubmissionTriggerValidateOperation: []string{}, ActionSubmissionValidatedOperation: []string{}, - AssetLocationOperation: []string{}, CreateMapfixOperation: []string{}, CreateMapfixAuditCommentOperation: []string{}, CreateScriptOperation: []string{}, @@ -66,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 426dad6..dfe14e3 100644 --- a/pkg/api/oas_server_gen.go +++ b/pkg/api/oas_server_gen.go @@ -142,12 +142,6 @@ type Handler interface { // // POST /submissions/{SubmissionID}/status/reset-uploading ActionSubmissionValidated(ctx context.Context, params ActionSubmissionValidatedParams) error - // AssetLocation implements assetLocation operation. - // - // Get location of asset. - // - // GET /asset/{AssetID}/location - AssetLocation(ctx context.Context, params AssetLocationParams) (AssetLocationOK, error) // CreateMapfix implements createMapfix operation. // // Trigger the validator to create a mapfix. @@ -208,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 3c23f29..2cba41c 100644 --- a/pkg/api/oas_unimplemented_gen.go +++ b/pkg/api/oas_unimplemented_gen.go @@ -213,15 +213,6 @@ func (UnimplementedHandler) ActionSubmissionValidated(ctx context.Context, param return ht.ErrNotImplemented } -// AssetLocation implements assetLocation operation. -// -// Get location of asset. -// -// GET /asset/{AssetID}/location -func (UnimplementedHandler) AssetLocation(ctx context.Context, params AssetLocationParams) (r AssetLocationOK, _ error) { - return r, ht.ErrNotImplemented -} - // CreateMapfix implements createMapfix operation. // // Trigger the validator to create a mapfix. @@ -312,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. -- 2.49.1 From 6a2f3f04e842e9b2a940e7984c968d9121e6661b Mon Sep 17 00:00:00 2001 From: Quaternions Date: Sat, 21 Jun 2025 21:25:53 -0700 Subject: [PATCH 12/19] submissions: move endpoint to maps --- pkg/service/asset.go | 26 -------------------------- pkg/service/maps.go | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 26 deletions(-) delete mode 100644 pkg/service/asset.go diff --git a/pkg/service/asset.go b/pkg/service/asset.go deleted file mode 100644 index 3b33719..0000000 --- a/pkg/service/asset.go +++ /dev/null @@ -1,26 +0,0 @@ -package service - -import ( - "context" - "strings" - - "git.itzana.me/strafesnet/maps-service/pkg/api" - "git.itzana.me/strafesnet/maps-service/pkg/roblox" -) - -// AssetLocation invokes assetLocation operation. -// -// Get location of asset. -// -// GET /asset/{AssetID}/location -func (svc *Service) AssetLocation(ctx context.Context, params api.AssetLocationParams) (ok api.AssetLocationOK, err error) { - info, err := svc.Roblox.GetAssetLocation(roblox.GetAssetLatestRequest{ - AssetID: uint64(params.AssetID), - }) - if err != nil{ - return ok, err - } - - ok.Data = strings.NewReader(info.Location) - return ok, nil -} diff --git a/pkg/service/maps.go b/pkg/service/maps.go index aa7b52f..6dd3e39 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,20 @@ 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) { + 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 +} -- 2.49.1 From 512cf4d5c41a364da259a7d2e0947f6efa0af262 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Sat, 21 Jun 2025 21:26:37 -0700 Subject: [PATCH 13/19] web: move endpoint to maps --- web/src/app/_components/downloadButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/_components/downloadButton.tsx b/web/src/app/_components/downloadButton.tsx index 9912788..cf3f7ac 100644 --- a/web/src/app/_components/downloadButton.tsx +++ b/web/src/app/_components/downloadButton.tsx @@ -13,7 +13,7 @@ const DownloadButton: React.FC = ({ assetId, assetName }) = const handleDownload = async () => { setDownloading(true); try { - const res = await fetch(`/asset/${assetId}/location`); + const res = await fetch(`/maps/${assetId}/location`); if (!res.ok) throw new Error('Failed to fetch download location'); const location = await res.text(); -- 2.49.1 From a4644a937c838d19644f1d62fd90b3c451f08f0d Mon Sep 17 00:00:00 2001 From: Quaternions Date: Sat, 21 Jun 2025 21:29:10 -0700 Subject: [PATCH 14/19] submissions: ensure map exists in db --- pkg/service/maps.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/service/maps.go b/pkg/service/maps.go index 6dd3e39..40ea3de 100644 --- a/pkg/service/maps.go +++ b/pkg/service/maps.go @@ -80,6 +80,15 @@ func (svc *Service) GetMap(ctx context.Context, params api.GetMapParams) (*api.M // // 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), }) -- 2.49.1 From 8b3e128d3b69dacfc3b0c3f23ed33d44a26c36a9 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Sun, 22 Jun 2025 00:35:39 -0700 Subject: [PATCH 15/19] Revert "rename env var" This reverts commit 3d4d7776743ba03d31af1b4791e41bec1b851d87. --- pkg/cmds/serve.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmds/serve.go b/pkg/cmds/serve.go index 034792a..39391c3 100644 --- a/pkg/cmds/serve.go +++ b/pkg/cmds/serve.go @@ -93,9 +93,9 @@ func NewServeCommand() *cli.Command { Value: "nats:4222", }, &cli.StringFlag{ - Name: "rbx-api-key-asset-download", + Name: "rbx-api-key", Usage: "API Key for downloading asset locations", - EnvVars: []string{"RBX_API_KEY_ASSET_DOWNLOAD"}, + EnvVars: []string{"RBX_API_KEY"}, Required: true, }, }, @@ -137,7 +137,7 @@ func serve(ctx *cli.Context) error { Users: users.NewUsersServiceClient(conn), Roblox: roblox.Client{ HttpClient: http.DefaultClient, - ApiKey: ctx.String("rbx-api-key-asset-download"), + ApiKey: ctx.String("rbx-api-key"), }, } -- 2.49.1 From 07c8db67a2489fc78ba6433bbfb95595eb98cafc Mon Sep 17 00:00:00 2001 From: Quaternions Date: Sun, 22 Jun 2025 00:41:29 -0700 Subject: [PATCH 16/19] docker: add env file to local compose --- compose.yaml | 2 ++ 1 file changed, 2 insertions(+) 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 -- 2.49.1 From a84e4c9f0cfed9b686e3551f019ffa5400b85c21 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Mon, 23 Jun 2025 21:40:42 -0700 Subject: [PATCH 17/19] claude bugten --- web/src/app/_components/downloadButton.tsx | 36 +++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/web/src/app/_components/downloadButton.tsx b/web/src/app/_components/downloadButton.tsx index cf3f7ac..72425c4 100644 --- a/web/src/app/_components/downloadButton.tsx +++ b/web/src/app/_components/downloadButton.tsx @@ -4,7 +4,7 @@ import BugReportIcon from '@mui/icons-material/BugReport'; interface DownloadButtonProps { assetId: number; - assetName: string; // Used for a more readable file name + assetName: string; } const DownloadButton: React.FC = ({ assetId, assetName }) => { @@ -13,19 +13,39 @@ const DownloadButton: React.FC = ({ assetId, assetName }) = const handleDownload = async () => { setDownloading(true); try { + // Fetch the download URL const res = await fetch(`/maps/${assetId}/location`); - if (!res.ok) throw new Error('Failed to fetch download location'); + const location = await res.text(); - const link = document.createElement('a'); - link.href = location; - link.download = `${assetName}.rbxm`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + // 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); } -- 2.49.1 From 050a59d3cb2e30f9eb16dedd047f4dbd7a9ca744 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Mon, 23 Jun 2025 21:54:29 -0700 Subject: [PATCH 18/19] wrong url --- web/src/app/_components/downloadButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/_components/downloadButton.tsx b/web/src/app/_components/downloadButton.tsx index 72425c4..e3dcaf8 100644 --- a/web/src/app/_components/downloadButton.tsx +++ b/web/src/app/_components/downloadButton.tsx @@ -14,7 +14,7 @@ const DownloadButton: React.FC = ({ assetId, assetName }) = setDownloading(true); try { // Fetch the download URL - const res = await fetch(`/maps/${assetId}/location`); + const res = await fetch(`/api/maps/${assetId}/location`); if (!res.ok) throw new Error('Failed to fetch download location'); const location = await res.text(); -- 2.49.1 From c52eec643a326f630337694863e6a0eb88b74f0b Mon Sep 17 00:00:00 2001 From: Quaternions Date: Mon, 23 Jun 2025 22:00:03 -0700 Subject: [PATCH 19/19] use download icon --- web/src/app/_components/downloadButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/app/_components/downloadButton.tsx b/web/src/app/_components/downloadButton.tsx index e3dcaf8..f144aaf 100644 --- a/web/src/app/_components/downloadButton.tsx +++ b/web/src/app/_components/downloadButton.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Button } from '@mui/material'; -import BugReportIcon from '@mui/icons-material/BugReport'; +import Download from '@mui/icons-material/Download'; interface DownloadButtonProps { assetId: number; @@ -56,7 +56,7 @@ const DownloadButton: React.FC = ({ assetId, assetName }) = fullWidth variant="contained" color="primary" - startIcon={} + startIcon={} size="large" onClick={handleDownload} disabled={downloading} -- 2.49.1