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 +}