Compare commits
62 Commits
hide-reset
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
| 70dd8502f4 | |||
| 5b977289e7 | |||
| 7b3af95f3d | |||
|
4d78a9b2c5
|
|||
|
ec59a83379
|
|||
| 54bf3f55a0 | |||
| 14f404ffe3 | |||
|
0e1d2fe50a
|
|||
|
ada8c322da
|
|||
|
84d2bfef20
|
|||
| 170e7c64b6 | |||
| b443866dd6 | |||
|
ebe37ad6a2
|
|||
|
131dad7ae0
|
|||
| 127402fa77 | |||
|
40f83a4e30
|
|||
|
b6d4ce4f80
|
|||
| 07391a84cb | |||
|
|
3f848a35c8 | ||
| e5e2387502 | |||
|
90d13d28ae
|
|||
| 513b9722b1 | |||
|
3da8e414e6
|
|||
|
2acc30e18c
|
|||
|
a990ed458c
|
|||
|
4055ef550e
|
|||
|
555844e6ee
|
|||
|
80f30d20fa
|
|||
|
489a8c9c10
|
|||
|
534598ba70
|
|||
|
fdc0240698
|
|||
|
b0829bc1fc
|
|||
|
845f8e69d9
|
|||
|
0d8937e896
|
|||
| 2927afd848 | |||
| 8f6c543f81 | |||
|
7c95f8ddd0
|
|||
|
24964407bd
|
|||
|
|
8d5bd9e523 | ||
|
|
e1fc637619 | ||
|
|
762ee874a0 | ||
| a1c84ff225 | |||
|
cea6242dd7
|
|||
| fefe116611 | |||
|
0ada77421f
|
|||
|
fa2d611534
|
|||
| 81539a606c | |||
| 32095296c2 | |||
|
8ea5ee2d41
|
|||
|
954dbaeac6
|
|||
| 5b7efa2426 | |||
|
|
740e3c8932 | ||
|
4f31f8c75a
|
|||
|
d39a8c0208
|
|||
|
636282b993
|
|||
|
e3667fec0c
|
|||
|
0dff464561
|
|||
|
e30fb5f916
|
|||
|
9dd7156f38
|
|||
|
fc9aae4235
|
|||
|
a11a0d2fd5
|
|||
|
9dad1a6b4d
|
664
Cargo.lock
generated
664
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ Prerequisite: bun installed
|
||||
The environment variables `API_HOST` and `AUTH_HOST` will need to be set for the middleware.
|
||||
Example `.env` in web's root:
|
||||
```
|
||||
API_HOST="http://localhost:8082/v1/"
|
||||
API_HOST="http://localhost:8082/"
|
||||
AUTH_HOST="http://localhost:8083/"
|
||||
```
|
||||
|
||||
|
||||
102
openapi.yaml
102
openapi.yaml
@@ -114,6 +114,13 @@ paths:
|
||||
format: int32
|
||||
minimum: 0
|
||||
maximum: 4
|
||||
description: >
|
||||
Sort order:
|
||||
* `0` - Disabled
|
||||
* `1` - DisplayNameAscending
|
||||
* `2` - DisplayNameDescending
|
||||
* `3` - DateAscending
|
||||
* `4` - DateDescending
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
@@ -178,6 +185,11 @@ paths:
|
||||
format: int32
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
description: >
|
||||
Game ID:
|
||||
* `1` - Bhop
|
||||
* `2` - Surf
|
||||
* `5` - FlyTrials
|
||||
- name: Sort
|
||||
in: query
|
||||
schema:
|
||||
@@ -185,6 +197,13 @@ paths:
|
||||
format: int32
|
||||
minimum: 0
|
||||
maximum: 4
|
||||
description: >
|
||||
Sort order:
|
||||
* `0` - Disabled
|
||||
* `1` - DisplayNameAscending
|
||||
* `2` - DisplayNameDescending
|
||||
* `3` - DateAscending
|
||||
* `4` - DateDescending
|
||||
- name: Submitter
|
||||
in: query
|
||||
schema:
|
||||
@@ -210,6 +229,24 @@ paths:
|
||||
format: int32
|
||||
minimum: 0
|
||||
maximum: 9
|
||||
description: >
|
||||
// Phase: Creation
|
||||
* `0` - UnderConstruction
|
||||
* `1` - ChangesRequested
|
||||
|
||||
// Phase: Review
|
||||
* `2` - Submitting
|
||||
* `3` - Submitted
|
||||
|
||||
// Phase: Testing
|
||||
* `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
|
||||
* `5` - Validating
|
||||
* `6` - Validated
|
||||
* `7` - Uploading
|
||||
|
||||
// Phase: Final MapfixStatus
|
||||
* `8` - Uploaded // uploaded to the group, but pending release
|
||||
* `9` - Rejected
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
@@ -384,6 +421,23 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
/mapfixes/{MapfixID}/status/bypass-submit:
|
||||
post:
|
||||
summary: Role Reviewer changes status from ChangesRequested -> Submitted
|
||||
operationId: actionMapfixBypassSubmit
|
||||
tags:
|
||||
- Mapfixes
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/MapfixID'
|
||||
responses:
|
||||
"204":
|
||||
description: Successful response
|
||||
default:
|
||||
description: General Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
/mapfixes/{MapfixID}/status/reset-submitting:
|
||||
post:
|
||||
summary: Role Submitter manually resets submitting softlock and changes status from Submitting -> UnderConstruction
|
||||
@@ -585,6 +639,11 @@ paths:
|
||||
format: int32
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
description: >
|
||||
Game ID:
|
||||
* `1` - Bhop
|
||||
* `2` - Surf
|
||||
* `5` - FlyTrials
|
||||
- name: Sort
|
||||
in: query
|
||||
schema:
|
||||
@@ -592,6 +651,13 @@ paths:
|
||||
format: int32
|
||||
minimum: 0
|
||||
maximum: 4
|
||||
description: >
|
||||
Sort order:
|
||||
* `0` - Disabled
|
||||
* `1` - DisplayNameAscending
|
||||
* `2` - DisplayNameDescending
|
||||
* `3` - DateAscending
|
||||
* `4` - DateDescending
|
||||
- name: Submitter
|
||||
in: query
|
||||
schema:
|
||||
@@ -617,6 +683,25 @@ paths:
|
||||
format: int32
|
||||
minimum: 0
|
||||
maximum: 10
|
||||
description: >
|
||||
// Phase: Creation
|
||||
* `0` - UnderConstruction
|
||||
* `1` - ChangesRequested
|
||||
|
||||
// Phase: Review
|
||||
* `2` - Submitting
|
||||
* `3` - Submitted
|
||||
|
||||
// Phase: Testing
|
||||
* `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
|
||||
* `5` - Validating
|
||||
* `6` - Validated
|
||||
* `7` - Uploading
|
||||
* `8` - Uploaded // uploaded to the group, but pending release
|
||||
|
||||
// Phase: Final SubmissionStatus
|
||||
* `9` - Rejected
|
||||
* `10` - Released
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
@@ -816,6 +901,23 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
/submissions/{SubmissionID}/status/bypass-submit:
|
||||
post:
|
||||
summary: Role Reviewer changes status from ChangesRequested -> Submitted
|
||||
operationId: actionSubmissionBypassSubmit
|
||||
tags:
|
||||
- Submissions
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/SubmissionID'
|
||||
responses:
|
||||
"204":
|
||||
description: Successful response
|
||||
default:
|
||||
description: General Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
/submissions/{SubmissionID}/status/reset-submitting:
|
||||
post:
|
||||
summary: Role Submitter manually resets submitting softlock and changes status from Submitting -> UnderConstruction
|
||||
|
||||
@@ -35,6 +35,12 @@ type Invoker interface {
|
||||
//
|
||||
// POST /mapfixes/{MapfixID}/status/reset-validating
|
||||
ActionMapfixAccepted(ctx context.Context, params ActionMapfixAcceptedParams) error
|
||||
// ActionMapfixBypassSubmit invokes actionMapfixBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /mapfixes/{MapfixID}/status/bypass-submit
|
||||
ActionMapfixBypassSubmit(ctx context.Context, params ActionMapfixBypassSubmitParams) error
|
||||
// ActionMapfixReject invokes actionMapfixReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
@@ -96,6 +102,12 @@ type Invoker interface {
|
||||
//
|
||||
// POST /submissions/{SubmissionID}/status/reset-validating
|
||||
ActionSubmissionAccepted(ctx context.Context, params ActionSubmissionAcceptedParams) error
|
||||
// ActionSubmissionBypassSubmit invokes actionSubmissionBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /submissions/{SubmissionID}/status/bypass-submit
|
||||
ActionSubmissionBypassSubmit(ctx context.Context, params ActionSubmissionBypassSubmitParams) error
|
||||
// ActionSubmissionReject invokes actionSubmissionReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
@@ -518,6 +530,130 @@ func (c *Client) sendActionMapfixAccepted(ctx context.Context, params ActionMapf
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ActionMapfixBypassSubmit invokes actionMapfixBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /mapfixes/{MapfixID}/status/bypass-submit
|
||||
func (c *Client) ActionMapfixBypassSubmit(ctx context.Context, params ActionMapfixBypassSubmitParams) error {
|
||||
_, err := c.sendActionMapfixBypassSubmit(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) sendActionMapfixBypassSubmit(ctx context.Context, params ActionMapfixBypassSubmitParams) (res *ActionMapfixBypassSubmitNoContent, err error) {
|
||||
otelAttrs := []attribute.KeyValue{
|
||||
otelogen.OperationID("actionMapfixBypassSubmit"),
|
||||
semconv.HTTPRequestMethodKey.String("POST"),
|
||||
semconv.HTTPRouteKey.String("/mapfixes/{MapfixID}/status/bypass-submit"),
|
||||
}
|
||||
|
||||
// Run stopwatch.
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
// Use floating point division here for higher precision (instead of Millisecond method).
|
||||
elapsedDuration := time.Since(startTime)
|
||||
c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...))
|
||||
}()
|
||||
|
||||
// Increment request counter.
|
||||
c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
|
||||
|
||||
// Start a span for this request.
|
||||
ctx, span := c.cfg.Tracer.Start(ctx, ActionMapfixBypassSubmitOperation,
|
||||
trace.WithAttributes(otelAttrs...),
|
||||
clientSpanKind,
|
||||
)
|
||||
// Track stage for error reporting.
|
||||
var stage string
|
||||
defer func() {
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, stage)
|
||||
c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
|
||||
}
|
||||
span.End()
|
||||
}()
|
||||
|
||||
stage = "BuildURL"
|
||||
u := uri.Clone(c.requestURL(ctx))
|
||||
var pathParts [3]string
|
||||
pathParts[0] = "/mapfixes/"
|
||||
{
|
||||
// Encode "MapfixID" parameter.
|
||||
e := uri.NewPathEncoder(uri.PathEncoderConfig{
|
||||
Param: "MapfixID",
|
||||
Style: uri.PathStyleSimple,
|
||||
Explode: false,
|
||||
})
|
||||
if err := func() error {
|
||||
return e.EncodeValue(conv.Int64ToString(params.MapfixID))
|
||||
}(); err != nil {
|
||||
return res, errors.Wrap(err, "encode path")
|
||||
}
|
||||
encoded, err := e.Result()
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "encode path")
|
||||
}
|
||||
pathParts[1] = encoded
|
||||
}
|
||||
pathParts[2] = "/status/bypass-submit"
|
||||
uri.AddPathParts(u, pathParts[:]...)
|
||||
|
||||
stage = "EncodeRequest"
|
||||
r, err := ht.NewRequest(ctx, "POST", u)
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "create request")
|
||||
}
|
||||
|
||||
{
|
||||
type bitset = [1]uint8
|
||||
var satisfied bitset
|
||||
{
|
||||
stage = "Security:CookieAuth"
|
||||
switch err := c.securityCookieAuth(ctx, ActionMapfixBypassSubmitOperation, r); {
|
||||
case err == nil: // if NO error
|
||||
satisfied[0] |= 1 << 0
|
||||
case errors.Is(err, ogenerrors.ErrSkipClientSecurity):
|
||||
// Skip this security.
|
||||
default:
|
||||
return res, errors.Wrap(err, "security \"CookieAuth\"")
|
||||
}
|
||||
}
|
||||
|
||||
if ok := func() bool {
|
||||
nextRequirement:
|
||||
for _, requirement := range []bitset{
|
||||
{0b00000001},
|
||||
} {
|
||||
for i, mask := range requirement {
|
||||
if satisfied[i]&mask != mask {
|
||||
continue nextRequirement
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}(); !ok {
|
||||
return res, ogenerrors.ErrSecurityRequirementIsNotSatisfied
|
||||
}
|
||||
}
|
||||
|
||||
stage = "SendRequest"
|
||||
resp, err := c.cfg.Client.Do(r)
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "do request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
stage = "DecodeResponse"
|
||||
result, err := decodeActionMapfixBypassSubmitResponse(resp)
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "decode response")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ActionMapfixReject invokes actionMapfixReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
@@ -1759,6 +1895,130 @@ func (c *Client) sendActionSubmissionAccepted(ctx context.Context, params Action
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ActionSubmissionBypassSubmit invokes actionSubmissionBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /submissions/{SubmissionID}/status/bypass-submit
|
||||
func (c *Client) ActionSubmissionBypassSubmit(ctx context.Context, params ActionSubmissionBypassSubmitParams) error {
|
||||
_, err := c.sendActionSubmissionBypassSubmit(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) sendActionSubmissionBypassSubmit(ctx context.Context, params ActionSubmissionBypassSubmitParams) (res *ActionSubmissionBypassSubmitNoContent, err error) {
|
||||
otelAttrs := []attribute.KeyValue{
|
||||
otelogen.OperationID("actionSubmissionBypassSubmit"),
|
||||
semconv.HTTPRequestMethodKey.String("POST"),
|
||||
semconv.HTTPRouteKey.String("/submissions/{SubmissionID}/status/bypass-submit"),
|
||||
}
|
||||
|
||||
// Run stopwatch.
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
// Use floating point division here for higher precision (instead of Millisecond method).
|
||||
elapsedDuration := time.Since(startTime)
|
||||
c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...))
|
||||
}()
|
||||
|
||||
// Increment request counter.
|
||||
c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
|
||||
|
||||
// Start a span for this request.
|
||||
ctx, span := c.cfg.Tracer.Start(ctx, ActionSubmissionBypassSubmitOperation,
|
||||
trace.WithAttributes(otelAttrs...),
|
||||
clientSpanKind,
|
||||
)
|
||||
// Track stage for error reporting.
|
||||
var stage string
|
||||
defer func() {
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, stage)
|
||||
c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
|
||||
}
|
||||
span.End()
|
||||
}()
|
||||
|
||||
stage = "BuildURL"
|
||||
u := uri.Clone(c.requestURL(ctx))
|
||||
var pathParts [3]string
|
||||
pathParts[0] = "/submissions/"
|
||||
{
|
||||
// Encode "SubmissionID" parameter.
|
||||
e := uri.NewPathEncoder(uri.PathEncoderConfig{
|
||||
Param: "SubmissionID",
|
||||
Style: uri.PathStyleSimple,
|
||||
Explode: false,
|
||||
})
|
||||
if err := func() error {
|
||||
return e.EncodeValue(conv.Int64ToString(params.SubmissionID))
|
||||
}(); err != nil {
|
||||
return res, errors.Wrap(err, "encode path")
|
||||
}
|
||||
encoded, err := e.Result()
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "encode path")
|
||||
}
|
||||
pathParts[1] = encoded
|
||||
}
|
||||
pathParts[2] = "/status/bypass-submit"
|
||||
uri.AddPathParts(u, pathParts[:]...)
|
||||
|
||||
stage = "EncodeRequest"
|
||||
r, err := ht.NewRequest(ctx, "POST", u)
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "create request")
|
||||
}
|
||||
|
||||
{
|
||||
type bitset = [1]uint8
|
||||
var satisfied bitset
|
||||
{
|
||||
stage = "Security:CookieAuth"
|
||||
switch err := c.securityCookieAuth(ctx, ActionSubmissionBypassSubmitOperation, r); {
|
||||
case err == nil: // if NO error
|
||||
satisfied[0] |= 1 << 0
|
||||
case errors.Is(err, ogenerrors.ErrSkipClientSecurity):
|
||||
// Skip this security.
|
||||
default:
|
||||
return res, errors.Wrap(err, "security \"CookieAuth\"")
|
||||
}
|
||||
}
|
||||
|
||||
if ok := func() bool {
|
||||
nextRequirement:
|
||||
for _, requirement := range []bitset{
|
||||
{0b00000001},
|
||||
} {
|
||||
for i, mask := range requirement {
|
||||
if satisfied[i]&mask != mask {
|
||||
continue nextRequirement
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}(); !ok {
|
||||
return res, ogenerrors.ErrSecurityRequirementIsNotSatisfied
|
||||
}
|
||||
}
|
||||
|
||||
stage = "SendRequest"
|
||||
resp, err := c.cfg.Client.Do(r)
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "do request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
stage = "DecodeResponse"
|
||||
result, err := decodeActionSubmissionBypassSubmitResponse(resp)
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "decode response")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ActionSubmissionReject invokes actionSubmissionReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
|
||||
@@ -225,6 +225,201 @@ func (s *Server) handleActionMapfixAcceptedRequest(args [1]string, argsEscaped b
|
||||
}
|
||||
}
|
||||
|
||||
// handleActionMapfixBypassSubmitRequest handles actionMapfixBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /mapfixes/{MapfixID}/status/bypass-submit
|
||||
func (s *Server) handleActionMapfixBypassSubmitRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) {
|
||||
statusWriter := &codeRecorder{ResponseWriter: w}
|
||||
w = statusWriter
|
||||
otelAttrs := []attribute.KeyValue{
|
||||
otelogen.OperationID("actionMapfixBypassSubmit"),
|
||||
semconv.HTTPRequestMethodKey.String("POST"),
|
||||
semconv.HTTPRouteKey.String("/mapfixes/{MapfixID}/status/bypass-submit"),
|
||||
}
|
||||
|
||||
// Start a span for this request.
|
||||
ctx, span := s.cfg.Tracer.Start(r.Context(), ActionMapfixBypassSubmitOperation,
|
||||
trace.WithAttributes(otelAttrs...),
|
||||
serverSpanKind,
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
// Add Labeler to context.
|
||||
labeler := &Labeler{attrs: otelAttrs}
|
||||
ctx = contextWithLabeler(ctx, labeler)
|
||||
|
||||
// Run stopwatch.
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
elapsedDuration := time.Since(startTime)
|
||||
|
||||
attrSet := labeler.AttributeSet()
|
||||
attrs := attrSet.ToSlice()
|
||||
code := statusWriter.status
|
||||
if code != 0 {
|
||||
codeAttr := semconv.HTTPResponseStatusCode(code)
|
||||
attrs = append(attrs, codeAttr)
|
||||
span.SetAttributes(codeAttr)
|
||||
}
|
||||
attrOpt := metric.WithAttributes(attrs...)
|
||||
|
||||
// Increment request counter.
|
||||
s.requests.Add(ctx, 1, attrOpt)
|
||||
|
||||
// Use floating point division here for higher precision (instead of Millisecond method).
|
||||
s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt)
|
||||
}()
|
||||
|
||||
var (
|
||||
recordError = func(stage string, err error) {
|
||||
span.RecordError(err)
|
||||
|
||||
// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status
|
||||
// Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges,
|
||||
// unless there was another error (e.g., network error receiving the response body; or 3xx codes with
|
||||
// max redirects exceeded), in which case status MUST be set to Error.
|
||||
code := statusWriter.status
|
||||
if code >= 100 && code < 500 {
|
||||
span.SetStatus(codes.Error, stage)
|
||||
}
|
||||
|
||||
attrSet := labeler.AttributeSet()
|
||||
attrs := attrSet.ToSlice()
|
||||
if code != 0 {
|
||||
attrs = append(attrs, semconv.HTTPResponseStatusCode(code))
|
||||
}
|
||||
|
||||
s.errors.Add(ctx, 1, metric.WithAttributes(attrs...))
|
||||
}
|
||||
err error
|
||||
opErrContext = ogenerrors.OperationContext{
|
||||
Name: ActionMapfixBypassSubmitOperation,
|
||||
ID: "actionMapfixBypassSubmit",
|
||||
}
|
||||
)
|
||||
{
|
||||
type bitset = [1]uint8
|
||||
var satisfied bitset
|
||||
{
|
||||
sctx, ok, err := s.securityCookieAuth(ctx, ActionMapfixBypassSubmitOperation, r)
|
||||
if err != nil {
|
||||
err = &ogenerrors.SecurityError{
|
||||
OperationContext: opErrContext,
|
||||
Security: "CookieAuth",
|
||||
Err: err,
|
||||
}
|
||||
if encodeErr := encodeErrorResponse(s.h.NewError(ctx, err), w, span); encodeErr != nil {
|
||||
defer recordError("Security:CookieAuth", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
satisfied[0] |= 1 << 0
|
||||
ctx = sctx
|
||||
}
|
||||
}
|
||||
|
||||
if ok := func() bool {
|
||||
nextRequirement:
|
||||
for _, requirement := range []bitset{
|
||||
{0b00000001},
|
||||
} {
|
||||
for i, mask := range requirement {
|
||||
if satisfied[i]&mask != mask {
|
||||
continue nextRequirement
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}(); !ok {
|
||||
err = &ogenerrors.SecurityError{
|
||||
OperationContext: opErrContext,
|
||||
Err: ogenerrors.ErrSecurityRequirementIsNotSatisfied,
|
||||
}
|
||||
if encodeErr := encodeErrorResponse(s.h.NewError(ctx, err), w, span); encodeErr != nil {
|
||||
defer recordError("Security", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
params, err := decodeActionMapfixBypassSubmitParams(args, argsEscaped, r)
|
||||
if err != nil {
|
||||
err = &ogenerrors.DecodeParamsError{
|
||||
OperationContext: opErrContext,
|
||||
Err: err,
|
||||
}
|
||||
defer recordError("DecodeParams", err)
|
||||
s.cfg.ErrorHandler(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var response *ActionMapfixBypassSubmitNoContent
|
||||
if m := s.cfg.Middleware; m != nil {
|
||||
mreq := middleware.Request{
|
||||
Context: ctx,
|
||||
OperationName: ActionMapfixBypassSubmitOperation,
|
||||
OperationSummary: "Role Reviewer changes status from ChangesRequested -> Submitted",
|
||||
OperationID: "actionMapfixBypassSubmit",
|
||||
Body: nil,
|
||||
Params: middleware.Parameters{
|
||||
{
|
||||
Name: "MapfixID",
|
||||
In: "path",
|
||||
}: params.MapfixID,
|
||||
},
|
||||
Raw: r,
|
||||
}
|
||||
|
||||
type (
|
||||
Request = struct{}
|
||||
Params = ActionMapfixBypassSubmitParams
|
||||
Response = *ActionMapfixBypassSubmitNoContent
|
||||
)
|
||||
response, err = middleware.HookMiddleware[
|
||||
Request,
|
||||
Params,
|
||||
Response,
|
||||
](
|
||||
m,
|
||||
mreq,
|
||||
unpackActionMapfixBypassSubmitParams,
|
||||
func(ctx context.Context, request Request, params Params) (response Response, err error) {
|
||||
err = s.h.ActionMapfixBypassSubmit(ctx, params)
|
||||
return response, err
|
||||
},
|
||||
)
|
||||
} else {
|
||||
err = s.h.ActionMapfixBypassSubmit(ctx, params)
|
||||
}
|
||||
if err != nil {
|
||||
if errRes, ok := errors.Into[*ErrorStatusCode](err); ok {
|
||||
if err := encodeErrorResponse(errRes, w, span); err != nil {
|
||||
defer recordError("Internal", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ht.ErrNotImplemented) {
|
||||
s.cfg.ErrorHandler(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil {
|
||||
defer recordError("Internal", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := encodeActionMapfixBypassSubmitResponse(response, w, span); err != nil {
|
||||
defer recordError("EncodeResponse", err)
|
||||
if !errors.Is(err, ht.ErrInternalServerErrorResponse) {
|
||||
s.cfg.ErrorHandler(ctx, w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleActionMapfixRejectRequest handles actionMapfixReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
@@ -2176,6 +2371,201 @@ func (s *Server) handleActionSubmissionAcceptedRequest(args [1]string, argsEscap
|
||||
}
|
||||
}
|
||||
|
||||
// handleActionSubmissionBypassSubmitRequest handles actionSubmissionBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /submissions/{SubmissionID}/status/bypass-submit
|
||||
func (s *Server) handleActionSubmissionBypassSubmitRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) {
|
||||
statusWriter := &codeRecorder{ResponseWriter: w}
|
||||
w = statusWriter
|
||||
otelAttrs := []attribute.KeyValue{
|
||||
otelogen.OperationID("actionSubmissionBypassSubmit"),
|
||||
semconv.HTTPRequestMethodKey.String("POST"),
|
||||
semconv.HTTPRouteKey.String("/submissions/{SubmissionID}/status/bypass-submit"),
|
||||
}
|
||||
|
||||
// Start a span for this request.
|
||||
ctx, span := s.cfg.Tracer.Start(r.Context(), ActionSubmissionBypassSubmitOperation,
|
||||
trace.WithAttributes(otelAttrs...),
|
||||
serverSpanKind,
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
// Add Labeler to context.
|
||||
labeler := &Labeler{attrs: otelAttrs}
|
||||
ctx = contextWithLabeler(ctx, labeler)
|
||||
|
||||
// Run stopwatch.
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
elapsedDuration := time.Since(startTime)
|
||||
|
||||
attrSet := labeler.AttributeSet()
|
||||
attrs := attrSet.ToSlice()
|
||||
code := statusWriter.status
|
||||
if code != 0 {
|
||||
codeAttr := semconv.HTTPResponseStatusCode(code)
|
||||
attrs = append(attrs, codeAttr)
|
||||
span.SetAttributes(codeAttr)
|
||||
}
|
||||
attrOpt := metric.WithAttributes(attrs...)
|
||||
|
||||
// Increment request counter.
|
||||
s.requests.Add(ctx, 1, attrOpt)
|
||||
|
||||
// Use floating point division here for higher precision (instead of Millisecond method).
|
||||
s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt)
|
||||
}()
|
||||
|
||||
var (
|
||||
recordError = func(stage string, err error) {
|
||||
span.RecordError(err)
|
||||
|
||||
// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status
|
||||
// Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges,
|
||||
// unless there was another error (e.g., network error receiving the response body; or 3xx codes with
|
||||
// max redirects exceeded), in which case status MUST be set to Error.
|
||||
code := statusWriter.status
|
||||
if code >= 100 && code < 500 {
|
||||
span.SetStatus(codes.Error, stage)
|
||||
}
|
||||
|
||||
attrSet := labeler.AttributeSet()
|
||||
attrs := attrSet.ToSlice()
|
||||
if code != 0 {
|
||||
attrs = append(attrs, semconv.HTTPResponseStatusCode(code))
|
||||
}
|
||||
|
||||
s.errors.Add(ctx, 1, metric.WithAttributes(attrs...))
|
||||
}
|
||||
err error
|
||||
opErrContext = ogenerrors.OperationContext{
|
||||
Name: ActionSubmissionBypassSubmitOperation,
|
||||
ID: "actionSubmissionBypassSubmit",
|
||||
}
|
||||
)
|
||||
{
|
||||
type bitset = [1]uint8
|
||||
var satisfied bitset
|
||||
{
|
||||
sctx, ok, err := s.securityCookieAuth(ctx, ActionSubmissionBypassSubmitOperation, r)
|
||||
if err != nil {
|
||||
err = &ogenerrors.SecurityError{
|
||||
OperationContext: opErrContext,
|
||||
Security: "CookieAuth",
|
||||
Err: err,
|
||||
}
|
||||
if encodeErr := encodeErrorResponse(s.h.NewError(ctx, err), w, span); encodeErr != nil {
|
||||
defer recordError("Security:CookieAuth", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
satisfied[0] |= 1 << 0
|
||||
ctx = sctx
|
||||
}
|
||||
}
|
||||
|
||||
if ok := func() bool {
|
||||
nextRequirement:
|
||||
for _, requirement := range []bitset{
|
||||
{0b00000001},
|
||||
} {
|
||||
for i, mask := range requirement {
|
||||
if satisfied[i]&mask != mask {
|
||||
continue nextRequirement
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}(); !ok {
|
||||
err = &ogenerrors.SecurityError{
|
||||
OperationContext: opErrContext,
|
||||
Err: ogenerrors.ErrSecurityRequirementIsNotSatisfied,
|
||||
}
|
||||
if encodeErr := encodeErrorResponse(s.h.NewError(ctx, err), w, span); encodeErr != nil {
|
||||
defer recordError("Security", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
params, err := decodeActionSubmissionBypassSubmitParams(args, argsEscaped, r)
|
||||
if err != nil {
|
||||
err = &ogenerrors.DecodeParamsError{
|
||||
OperationContext: opErrContext,
|
||||
Err: err,
|
||||
}
|
||||
defer recordError("DecodeParams", err)
|
||||
s.cfg.ErrorHandler(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var response *ActionSubmissionBypassSubmitNoContent
|
||||
if m := s.cfg.Middleware; m != nil {
|
||||
mreq := middleware.Request{
|
||||
Context: ctx,
|
||||
OperationName: ActionSubmissionBypassSubmitOperation,
|
||||
OperationSummary: "Role Reviewer changes status from ChangesRequested -> Submitted",
|
||||
OperationID: "actionSubmissionBypassSubmit",
|
||||
Body: nil,
|
||||
Params: middleware.Parameters{
|
||||
{
|
||||
Name: "SubmissionID",
|
||||
In: "path",
|
||||
}: params.SubmissionID,
|
||||
},
|
||||
Raw: r,
|
||||
}
|
||||
|
||||
type (
|
||||
Request = struct{}
|
||||
Params = ActionSubmissionBypassSubmitParams
|
||||
Response = *ActionSubmissionBypassSubmitNoContent
|
||||
)
|
||||
response, err = middleware.HookMiddleware[
|
||||
Request,
|
||||
Params,
|
||||
Response,
|
||||
](
|
||||
m,
|
||||
mreq,
|
||||
unpackActionSubmissionBypassSubmitParams,
|
||||
func(ctx context.Context, request Request, params Params) (response Response, err error) {
|
||||
err = s.h.ActionSubmissionBypassSubmit(ctx, params)
|
||||
return response, err
|
||||
},
|
||||
)
|
||||
} else {
|
||||
err = s.h.ActionSubmissionBypassSubmit(ctx, params)
|
||||
}
|
||||
if err != nil {
|
||||
if errRes, ok := errors.Into[*ErrorStatusCode](err); ok {
|
||||
if err := encodeErrorResponse(errRes, w, span); err != nil {
|
||||
defer recordError("Internal", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ht.ErrNotImplemented) {
|
||||
s.cfg.ErrorHandler(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil {
|
||||
defer recordError("Internal", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := encodeActionSubmissionBypassSubmitResponse(response, w, span); err != nil {
|
||||
defer recordError("EncodeResponse", err)
|
||||
if !errors.Is(err, ht.ErrInternalServerErrorResponse) {
|
||||
s.cfg.ErrorHandler(ctx, w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleActionSubmissionRejectRequest handles actionSubmissionReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
|
||||
@@ -7,6 +7,7 @@ type OperationName = string
|
||||
|
||||
const (
|
||||
ActionMapfixAcceptedOperation OperationName = "ActionMapfixAccepted"
|
||||
ActionMapfixBypassSubmitOperation OperationName = "ActionMapfixBypassSubmit"
|
||||
ActionMapfixRejectOperation OperationName = "ActionMapfixReject"
|
||||
ActionMapfixRequestChangesOperation OperationName = "ActionMapfixRequestChanges"
|
||||
ActionMapfixResetSubmittingOperation OperationName = "ActionMapfixResetSubmitting"
|
||||
@@ -17,6 +18,7 @@ const (
|
||||
ActionMapfixTriggerValidateOperation OperationName = "ActionMapfixTriggerValidate"
|
||||
ActionMapfixValidatedOperation OperationName = "ActionMapfixValidated"
|
||||
ActionSubmissionAcceptedOperation OperationName = "ActionSubmissionAccepted"
|
||||
ActionSubmissionBypassSubmitOperation OperationName = "ActionSubmissionBypassSubmit"
|
||||
ActionSubmissionRejectOperation OperationName = "ActionSubmissionReject"
|
||||
ActionSubmissionRequestChangesOperation OperationName = "ActionSubmissionRequestChanges"
|
||||
ActionSubmissionResetSubmittingOperation OperationName = "ActionSubmissionResetSubmitting"
|
||||
|
||||
@@ -98,6 +98,89 @@ func decodeActionMapfixAcceptedParams(args [1]string, argsEscaped bool, r *http.
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// ActionMapfixBypassSubmitParams is parameters of actionMapfixBypassSubmit operation.
|
||||
type ActionMapfixBypassSubmitParams struct {
|
||||
// The unique identifier for a mapfix.
|
||||
MapfixID int64
|
||||
}
|
||||
|
||||
func unpackActionMapfixBypassSubmitParams(packed middleware.Parameters) (params ActionMapfixBypassSubmitParams) {
|
||||
{
|
||||
key := middleware.ParameterKey{
|
||||
Name: "MapfixID",
|
||||
In: "path",
|
||||
}
|
||||
params.MapfixID = packed[key].(int64)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func decodeActionMapfixBypassSubmitParams(args [1]string, argsEscaped bool, r *http.Request) (params ActionMapfixBypassSubmitParams, _ error) {
|
||||
// Decode path: MapfixID.
|
||||
if err := func() error {
|
||||
param := args[0]
|
||||
if argsEscaped {
|
||||
unescaped, err := url.PathUnescape(args[0])
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unescape path")
|
||||
}
|
||||
param = unescaped
|
||||
}
|
||||
if len(param) > 0 {
|
||||
d := uri.NewPathDecoder(uri.PathDecoderConfig{
|
||||
Param: "MapfixID",
|
||||
Value: param,
|
||||
Style: uri.PathStyleSimple,
|
||||
Explode: false,
|
||||
})
|
||||
|
||||
if err := func() error {
|
||||
val, err := d.DecodeValue()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := conv.ToInt64(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params.MapfixID = c
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := func() error {
|
||||
if err := (validate.Int{
|
||||
MinSet: true,
|
||||
Min: 0,
|
||||
MaxSet: false,
|
||||
Max: 0,
|
||||
MinExclusive: false,
|
||||
MaxExclusive: false,
|
||||
MultipleOfSet: false,
|
||||
MultipleOf: 0,
|
||||
}).Validate(int64(params.MapfixID)); err != nil {
|
||||
return errors.Wrap(err, "int")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return validate.ErrFieldRequired
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return params, &ogenerrors.DecodeParamError{
|
||||
Name: "MapfixID",
|
||||
In: "path",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// ActionMapfixRejectParams is parameters of actionMapfixReject operation.
|
||||
type ActionMapfixRejectParams struct {
|
||||
// The unique identifier for a mapfix.
|
||||
@@ -928,6 +1011,89 @@ func decodeActionSubmissionAcceptedParams(args [1]string, argsEscaped bool, r *h
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// ActionSubmissionBypassSubmitParams is parameters of actionSubmissionBypassSubmit operation.
|
||||
type ActionSubmissionBypassSubmitParams struct {
|
||||
// The unique identifier for a submission.
|
||||
SubmissionID int64
|
||||
}
|
||||
|
||||
func unpackActionSubmissionBypassSubmitParams(packed middleware.Parameters) (params ActionSubmissionBypassSubmitParams) {
|
||||
{
|
||||
key := middleware.ParameterKey{
|
||||
Name: "SubmissionID",
|
||||
In: "path",
|
||||
}
|
||||
params.SubmissionID = packed[key].(int64)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func decodeActionSubmissionBypassSubmitParams(args [1]string, argsEscaped bool, r *http.Request) (params ActionSubmissionBypassSubmitParams, _ error) {
|
||||
// Decode path: SubmissionID.
|
||||
if err := func() error {
|
||||
param := args[0]
|
||||
if argsEscaped {
|
||||
unescaped, err := url.PathUnescape(args[0])
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unescape path")
|
||||
}
|
||||
param = unescaped
|
||||
}
|
||||
if len(param) > 0 {
|
||||
d := uri.NewPathDecoder(uri.PathDecoderConfig{
|
||||
Param: "SubmissionID",
|
||||
Value: param,
|
||||
Style: uri.PathStyleSimple,
|
||||
Explode: false,
|
||||
})
|
||||
|
||||
if err := func() error {
|
||||
val, err := d.DecodeValue()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := conv.ToInt64(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params.SubmissionID = c
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := func() error {
|
||||
if err := (validate.Int{
|
||||
MinSet: true,
|
||||
Min: 0,
|
||||
MaxSet: false,
|
||||
Max: 0,
|
||||
MinExclusive: false,
|
||||
MaxExclusive: false,
|
||||
MultipleOfSet: false,
|
||||
MultipleOf: 0,
|
||||
}).Validate(int64(params.SubmissionID)); err != nil {
|
||||
return errors.Wrap(err, "int")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return validate.ErrFieldRequired
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return params, &ogenerrors.DecodeParamError{
|
||||
Name: "SubmissionID",
|
||||
In: "path",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// ActionSubmissionRejectParams is parameters of actionSubmissionReject operation.
|
||||
type ActionSubmissionRejectParams struct {
|
||||
// The unique identifier for a submission.
|
||||
@@ -2713,16 +2879,25 @@ func decodeListMapfixAuditEventsParams(args [1]string, argsEscaped bool, r *http
|
||||
|
||||
// ListMapfixesParams is parameters of listMapfixes operation.
|
||||
type ListMapfixesParams struct {
|
||||
Page int32
|
||||
Limit int32
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
GameID OptInt32
|
||||
Page int32
|
||||
Limit int32
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
// Game ID: * `1` - Bhop * `2` - Surf * `5` - FlyTrials.
|
||||
GameID OptInt32
|
||||
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
|
||||
// DateAscending * `4` - DateDescending.
|
||||
Sort OptInt32
|
||||
Submitter OptInt64
|
||||
AssetID OptInt64
|
||||
TargetAssetID OptInt64
|
||||
StatusID OptInt32
|
||||
// // Phase: Creation * `0` - UnderConstruction * `1` - ChangesRequested
|
||||
// // Phase: Review * `2` - Submitting * `3` - Submitted
|
||||
// // Phase: Testing * `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
|
||||
// * `5` - Validating * `6` - Validated * `7` - Uploading
|
||||
// // Phase: Final MapfixStatus * `8` - Uploaded // uploaded to the group, but pending release * `9`
|
||||
// - Rejected.
|
||||
StatusID OptInt32
|
||||
}
|
||||
|
||||
func unpackListMapfixesParams(packed middleware.Parameters) (params ListMapfixesParams) {
|
||||
@@ -3451,7 +3626,9 @@ type ListMapsParams struct {
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
GameID OptInt32
|
||||
Sort OptInt32
|
||||
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
|
||||
// DateAscending * `4` - DateDescending.
|
||||
Sort OptInt32
|
||||
}
|
||||
|
||||
func unpackListMapsParams(packed middleware.Parameters) (params ListMapsParams) {
|
||||
@@ -4951,16 +5128,25 @@ func decodeListSubmissionAuditEventsParams(args [1]string, argsEscaped bool, r *
|
||||
|
||||
// ListSubmissionsParams is parameters of listSubmissions operation.
|
||||
type ListSubmissionsParams struct {
|
||||
Page int32
|
||||
Limit int32
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
GameID OptInt32
|
||||
Page int32
|
||||
Limit int32
|
||||
DisplayName OptString
|
||||
Creator OptString
|
||||
// Game ID: * `1` - Bhop * `2` - Surf * `5` - FlyTrials.
|
||||
GameID OptInt32
|
||||
// Sort order: * `0` - Disabled * `1` - DisplayNameAscending * `2` - DisplayNameDescending * `3` -
|
||||
// DateAscending * `4` - DateDescending.
|
||||
Sort OptInt32
|
||||
Submitter OptInt64
|
||||
AssetID OptInt64
|
||||
UploadedAssetID OptInt64
|
||||
StatusID OptInt32
|
||||
// // Phase: Creation * `0` - UnderConstruction * `1` - ChangesRequested
|
||||
// // Phase: Review * `2` - Submitting * `3` - Submitted
|
||||
// // Phase: Testing * `4` - AcceptedUnvalidated // pending script review, can re-trigger validation
|
||||
// * `5` - Validating * `6` - Validated * `7` - Uploading * `8` - Uploaded // uploaded to the group,
|
||||
// but pending release
|
||||
// // Phase: Final SubmissionStatus * `9` - Rejected * `10` - Released.
|
||||
StatusID OptInt32
|
||||
}
|
||||
|
||||
func unpackListSubmissionsParams(packed middleware.Parameters) (params ListSubmissionsParams) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/go-faster/errors"
|
||||
"github.com/go-faster/jx"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"github.com/ogen-go/ogen/ogenerrors"
|
||||
"github.com/ogen-go/ogen/validate"
|
||||
@@ -27,13 +26,13 @@ func (s *Server) decodeCreateMapfixRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -98,13 +97,13 @@ func (s *Server) decodeCreateMapfixAuditCommentRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -132,13 +131,13 @@ func (s *Server) decodeCreateScriptRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -203,13 +202,13 @@ func (s *Server) decodeCreateScriptPolicyRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -274,13 +273,13 @@ func (s *Server) decodeCreateSubmissionRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -345,13 +344,13 @@ func (s *Server) decodeCreateSubmissionAdminRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -416,13 +415,13 @@ func (s *Server) decodeCreateSubmissionAuditCommentRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -450,13 +449,13 @@ func (s *Server) decodeReleaseSubmissionsRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -554,13 +553,13 @@ func (s *Server) decodeUpdateScriptRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -625,13 +624,13 @@ func (s *Server) decodeUpdateScriptPolicyRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
|
||||
@@ -75,6 +75,66 @@ func decodeActionMapfixAcceptedResponse(resp *http.Response) (res *ActionMapfixA
|
||||
return res, errors.Wrap(defRes, "error")
|
||||
}
|
||||
|
||||
func decodeActionMapfixBypassSubmitResponse(resp *http.Response) (res *ActionMapfixBypassSubmitNoContent, _ error) {
|
||||
switch resp.StatusCode {
|
||||
case 204:
|
||||
// Code 204.
|
||||
return &ActionMapfixBypassSubmitNoContent{}, nil
|
||||
}
|
||||
// Convenient error response.
|
||||
defRes, err := func() (res *ErrorStatusCode, err error) {
|
||||
ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var response Error
|
||||
if err := func() error {
|
||||
if err := response.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
// Validate response.
|
||||
if err := func() error {
|
||||
if err := response.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return res, errors.Wrap(err, "validate")
|
||||
}
|
||||
return &ErrorStatusCode{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: response,
|
||||
}, nil
|
||||
default:
|
||||
return res, validate.InvalidContentType(ct)
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode)
|
||||
}
|
||||
return res, errors.Wrap(defRes, "error")
|
||||
}
|
||||
|
||||
func decodeActionMapfixRejectResponse(resp *http.Response) (res *ActionMapfixRejectNoContent, _ error) {
|
||||
switch resp.StatusCode {
|
||||
case 204:
|
||||
@@ -675,6 +735,66 @@ func decodeActionSubmissionAcceptedResponse(resp *http.Response) (res *ActionSub
|
||||
return res, errors.Wrap(defRes, "error")
|
||||
}
|
||||
|
||||
func decodeActionSubmissionBypassSubmitResponse(resp *http.Response) (res *ActionSubmissionBypassSubmitNoContent, _ error) {
|
||||
switch resp.StatusCode {
|
||||
case 204:
|
||||
// Code 204.
|
||||
return &ActionSubmissionBypassSubmitNoContent{}, nil
|
||||
}
|
||||
// Convenient error response.
|
||||
defRes, err := func() (res *ErrorStatusCode, err error) {
|
||||
ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return res, errors.Wrap(err, "parse media type")
|
||||
}
|
||||
switch {
|
||||
case ct == "application/json":
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
d := jx.DecodeBytes(buf)
|
||||
|
||||
var response Error
|
||||
if err := func() error {
|
||||
if err := response.Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Skip(); err != io.EOF {
|
||||
return errors.New("unexpected trailing data")
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
err = &ogenerrors.DecodeBodyError{
|
||||
ContentType: ct,
|
||||
Body: buf,
|
||||
Err: err,
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
// Validate response.
|
||||
if err := func() error {
|
||||
if err := response.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return res, errors.Wrap(err, "validate")
|
||||
}
|
||||
return &ErrorStatusCode{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: response,
|
||||
}, nil
|
||||
default:
|
||||
return res, validate.InvalidContentType(ct)
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode)
|
||||
}
|
||||
return res, errors.Wrap(defRes, "error")
|
||||
}
|
||||
|
||||
func decodeActionSubmissionRejectResponse(resp *http.Response) (res *ActionSubmissionRejectNoContent, _ error) {
|
||||
switch resp.StatusCode {
|
||||
case 204:
|
||||
|
||||
@@ -20,6 +20,13 @@ func encodeActionMapfixAcceptedResponse(response *ActionMapfixAcceptedNoContent,
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeActionMapfixBypassSubmitResponse(response *ActionMapfixBypassSubmitNoContent, w http.ResponseWriter, span trace.Span) error {
|
||||
w.WriteHeader(204)
|
||||
span.SetStatus(codes.Ok, http.StatusText(204))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeActionMapfixRejectResponse(response *ActionMapfixRejectNoContent, w http.ResponseWriter, span trace.Span) error {
|
||||
w.WriteHeader(204)
|
||||
span.SetStatus(codes.Ok, http.StatusText(204))
|
||||
@@ -90,6 +97,13 @@ func encodeActionSubmissionAcceptedResponse(response *ActionSubmissionAcceptedNo
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeActionSubmissionBypassSubmitResponse(response *ActionSubmissionBypassSubmitNoContent, w http.ResponseWriter, span trace.Span) error {
|
||||
w.WriteHeader(204)
|
||||
span.SetStatus(codes.Ok, http.StatusText(204))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeActionSubmissionRejectResponse(response *ActionSubmissionRejectNoContent, w http.ResponseWriter, span trace.Span) error {
|
||||
w.WriteHeader(204)
|
||||
span.SetStatus(codes.Ok, http.StatusText(204))
|
||||
|
||||
@@ -250,6 +250,28 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
break
|
||||
}
|
||||
switch elem[0] {
|
||||
case 'b': // Prefix: "bypass-submit"
|
||||
|
||||
if l := len("bypass-submit"); len(elem) >= l && elem[0:l] == "bypass-submit" {
|
||||
elem = elem[l:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if len(elem) == 0 {
|
||||
// Leaf node.
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
s.handleActionMapfixBypassSubmitRequest([1]string{
|
||||
args[0],
|
||||
}, elemIsEscaped, w, r)
|
||||
default:
|
||||
s.notAllowed(w, r, "POST")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'r': // Prefix: "re"
|
||||
|
||||
if l := len("re"); len(elem) >= l && elem[0:l] == "re" {
|
||||
@@ -1046,6 +1068,28 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
break
|
||||
}
|
||||
switch elem[0] {
|
||||
case 'b': // Prefix: "bypass-submit"
|
||||
|
||||
if l := len("bypass-submit"); len(elem) >= l && elem[0:l] == "bypass-submit" {
|
||||
elem = elem[l:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if len(elem) == 0 {
|
||||
// Leaf node.
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
s.handleActionSubmissionBypassSubmitRequest([1]string{
|
||||
args[0],
|
||||
}, elemIsEscaped, w, r)
|
||||
default:
|
||||
s.notAllowed(w, r, "POST")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'r': // Prefix: "re"
|
||||
|
||||
if l := len("re"); len(elem) >= l && elem[0:l] == "re" {
|
||||
@@ -1621,6 +1665,30 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) {
|
||||
break
|
||||
}
|
||||
switch elem[0] {
|
||||
case 'b': // Prefix: "bypass-submit"
|
||||
|
||||
if l := len("bypass-submit"); len(elem) >= l && elem[0:l] == "bypass-submit" {
|
||||
elem = elem[l:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if len(elem) == 0 {
|
||||
// Leaf node.
|
||||
switch method {
|
||||
case "POST":
|
||||
r.name = ActionMapfixBypassSubmitOperation
|
||||
r.summary = "Role Reviewer changes status from ChangesRequested -> Submitted"
|
||||
r.operationID = "actionMapfixBypassSubmit"
|
||||
r.pathPattern = "/mapfixes/{MapfixID}/status/bypass-submit"
|
||||
r.args = args
|
||||
r.count = 1
|
||||
return r, true
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case 'r': // Prefix: "re"
|
||||
|
||||
if l := len("re"); len(elem) >= l && elem[0:l] == "re" {
|
||||
@@ -2525,6 +2593,30 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) {
|
||||
break
|
||||
}
|
||||
switch elem[0] {
|
||||
case 'b': // Prefix: "bypass-submit"
|
||||
|
||||
if l := len("bypass-submit"); len(elem) >= l && elem[0:l] == "bypass-submit" {
|
||||
elem = elem[l:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if len(elem) == 0 {
|
||||
// Leaf node.
|
||||
switch method {
|
||||
case "POST":
|
||||
r.name = ActionSubmissionBypassSubmitOperation
|
||||
r.summary = "Role Reviewer changes status from ChangesRequested -> Submitted"
|
||||
r.operationID = "actionSubmissionBypassSubmit"
|
||||
r.pathPattern = "/submissions/{SubmissionID}/status/bypass-submit"
|
||||
r.args = args
|
||||
r.count = 1
|
||||
return r, true
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case 'r': // Prefix: "re"
|
||||
|
||||
if l := len("re"); len(elem) >= l && elem[0:l] == "re" {
|
||||
|
||||
@@ -17,6 +17,9 @@ func (s *ErrorStatusCode) Error() string {
|
||||
// ActionMapfixAcceptedNoContent is response for ActionMapfixAccepted operation.
|
||||
type ActionMapfixAcceptedNoContent struct{}
|
||||
|
||||
// ActionMapfixBypassSubmitNoContent is response for ActionMapfixBypassSubmit operation.
|
||||
type ActionMapfixBypassSubmitNoContent struct{}
|
||||
|
||||
// ActionMapfixRejectNoContent is response for ActionMapfixReject operation.
|
||||
type ActionMapfixRejectNoContent struct{}
|
||||
|
||||
@@ -47,6 +50,9 @@ type ActionMapfixValidatedNoContent struct{}
|
||||
// ActionSubmissionAcceptedNoContent is response for ActionSubmissionAccepted operation.
|
||||
type ActionSubmissionAcceptedNoContent struct{}
|
||||
|
||||
// ActionSubmissionBypassSubmitNoContent is response for ActionSubmissionBypassSubmit operation.
|
||||
type ActionSubmissionBypassSubmitNoContent struct{}
|
||||
|
||||
// ActionSubmissionRejectNoContent is response for ActionSubmissionReject operation.
|
||||
type ActionSubmissionRejectNoContent struct{}
|
||||
|
||||
@@ -182,6 +188,7 @@ func (s *AuditEventEventData) init() AuditEventEventData {
|
||||
|
||||
type CookieAuth struct {
|
||||
APIKey string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// GetAPIKey returns the value of APIKey.
|
||||
@@ -189,11 +196,21 @@ func (s *CookieAuth) GetAPIKey() string {
|
||||
return s.APIKey
|
||||
}
|
||||
|
||||
// GetRoles returns the value of Roles.
|
||||
func (s *CookieAuth) GetRoles() []string {
|
||||
return s.Roles
|
||||
}
|
||||
|
||||
// SetAPIKey sets the value of APIKey.
|
||||
func (s *CookieAuth) SetAPIKey(val string) {
|
||||
s.APIKey = val
|
||||
}
|
||||
|
||||
// SetRoles sets the value of Roles.
|
||||
func (s *CookieAuth) SetRoles(val []string) {
|
||||
s.Roles = val
|
||||
}
|
||||
|
||||
// CreateMapfixAuditCommentNoContent is response for CreateMapfixAuditComment operation.
|
||||
type CreateMapfixAuditCommentNoContent struct{}
|
||||
|
||||
|
||||
@@ -33,6 +33,51 @@ func findAuthorization(h http.Header, prefix string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var operationRolesCookieAuth = map[string][]string{
|
||||
ActionMapfixAcceptedOperation: []string{},
|
||||
ActionMapfixBypassSubmitOperation: []string{},
|
||||
ActionMapfixRejectOperation: []string{},
|
||||
ActionMapfixRequestChangesOperation: []string{},
|
||||
ActionMapfixResetSubmittingOperation: []string{},
|
||||
ActionMapfixRetryValidateOperation: []string{},
|
||||
ActionMapfixRevokeOperation: []string{},
|
||||
ActionMapfixTriggerSubmitOperation: []string{},
|
||||
ActionMapfixTriggerUploadOperation: []string{},
|
||||
ActionMapfixTriggerValidateOperation: []string{},
|
||||
ActionMapfixValidatedOperation: []string{},
|
||||
ActionSubmissionAcceptedOperation: []string{},
|
||||
ActionSubmissionBypassSubmitOperation: []string{},
|
||||
ActionSubmissionRejectOperation: []string{},
|
||||
ActionSubmissionRequestChangesOperation: []string{},
|
||||
ActionSubmissionResetSubmittingOperation: []string{},
|
||||
ActionSubmissionRetryValidateOperation: []string{},
|
||||
ActionSubmissionRevokeOperation: []string{},
|
||||
ActionSubmissionTriggerSubmitOperation: []string{},
|
||||
ActionSubmissionTriggerUploadOperation: []string{},
|
||||
ActionSubmissionTriggerValidateOperation: []string{},
|
||||
ActionSubmissionValidatedOperation: []string{},
|
||||
CreateMapfixOperation: []string{},
|
||||
CreateMapfixAuditCommentOperation: []string{},
|
||||
CreateScriptOperation: []string{},
|
||||
CreateScriptPolicyOperation: []string{},
|
||||
CreateSubmissionOperation: []string{},
|
||||
CreateSubmissionAdminOperation: []string{},
|
||||
CreateSubmissionAuditCommentOperation: []string{},
|
||||
DeleteScriptOperation: []string{},
|
||||
DeleteScriptPolicyOperation: []string{},
|
||||
GetOperationOperation: []string{},
|
||||
ReleaseSubmissionsOperation: []string{},
|
||||
SessionRolesOperation: []string{},
|
||||
SessionUserOperation: []string{},
|
||||
SessionValidateOperation: []string{},
|
||||
SetMapfixCompletedOperation: []string{},
|
||||
SetSubmissionCompletedOperation: []string{},
|
||||
UpdateMapfixModelOperation: []string{},
|
||||
UpdateScriptOperation: []string{},
|
||||
UpdateScriptPolicyOperation: []string{},
|
||||
UpdateSubmissionModelOperation: []string{},
|
||||
}
|
||||
|
||||
func (s *Server) securityCookieAuth(ctx context.Context, operationName OperationName, req *http.Request) (context.Context, bool, error) {
|
||||
var t CookieAuth
|
||||
const parameterName = "session_id"
|
||||
@@ -46,6 +91,7 @@ func (s *Server) securityCookieAuth(ctx context.Context, operationName Operation
|
||||
return nil, false, errors.Wrap(err, "get cookie value")
|
||||
}
|
||||
t.APIKey = value
|
||||
t.Roles = operationRolesCookieAuth[operationName]
|
||||
rctx, err := s.sec.HandleCookieAuth(ctx, operationName, t)
|
||||
if errors.Is(err, ogenerrors.ErrSkipServerSecurity) {
|
||||
return nil, false, nil
|
||||
|
||||
@@ -14,6 +14,12 @@ type Handler interface {
|
||||
//
|
||||
// POST /mapfixes/{MapfixID}/status/reset-validating
|
||||
ActionMapfixAccepted(ctx context.Context, params ActionMapfixAcceptedParams) error
|
||||
// ActionMapfixBypassSubmit implements actionMapfixBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /mapfixes/{MapfixID}/status/bypass-submit
|
||||
ActionMapfixBypassSubmit(ctx context.Context, params ActionMapfixBypassSubmitParams) error
|
||||
// ActionMapfixReject implements actionMapfixReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
@@ -75,6 +81,12 @@ type Handler interface {
|
||||
//
|
||||
// POST /submissions/{SubmissionID}/status/reset-validating
|
||||
ActionSubmissionAccepted(ctx context.Context, params ActionSubmissionAcceptedParams) error
|
||||
// ActionSubmissionBypassSubmit implements actionSubmissionBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /submissions/{SubmissionID}/status/bypass-submit
|
||||
ActionSubmissionBypassSubmit(ctx context.Context, params ActionSubmissionBypassSubmitParams) error
|
||||
// ActionSubmissionReject implements actionSubmissionReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
|
||||
@@ -22,6 +22,15 @@ func (UnimplementedHandler) ActionMapfixAccepted(ctx context.Context, params Act
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ActionMapfixBypassSubmit implements actionMapfixBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /mapfixes/{MapfixID}/status/bypass-submit
|
||||
func (UnimplementedHandler) ActionMapfixBypassSubmit(ctx context.Context, params ActionMapfixBypassSubmitParams) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ActionMapfixReject implements actionMapfixReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
@@ -113,6 +122,15 @@ func (UnimplementedHandler) ActionSubmissionAccepted(ctx context.Context, params
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ActionSubmissionBypassSubmit implements actionSubmissionBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /submissions/{SubmissionID}/status/bypass-submit
|
||||
func (UnimplementedHandler) ActionSubmissionBypassSubmit(ctx context.Context, params ActionSubmissionBypassSubmitParams) error {
|
||||
return ht.ErrNotImplemented
|
||||
}
|
||||
|
||||
// ActionSubmissionReject implements actionSubmissionReject operation.
|
||||
//
|
||||
// Role Reviewer changes status from Submitted -> Rejected.
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/go-faster/errors"
|
||||
"github.com/go-faster/jx"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"github.com/ogen-go/ogen/ogenerrors"
|
||||
"github.com/ogen-go/ogen/validate"
|
||||
@@ -26,13 +25,13 @@ func (s *Server) decodeCreateMapfixRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -97,13 +96,13 @@ func (s *Server) decodeCreateScriptRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -168,13 +167,13 @@ func (s *Server) decodeCreateScriptPolicyRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
@@ -239,13 +238,13 @@ func (s *Server) decodeCreateSubmissionRequest(r *http.Request) (
|
||||
// Close in reverse order, to match defer behavior.
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
c := closers[i]
|
||||
merr = multierr.Append(merr, c())
|
||||
merr = errors.Join(merr, c())
|
||||
}
|
||||
return merr
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
rerr = multierr.Append(rerr, close())
|
||||
rerr = errors.Join(rerr, close())
|
||||
}
|
||||
}()
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
|
||||
@@ -25,15 +25,24 @@ func (svc *Service) CreateMapfixAuditComment(ctx context.Context, req api.Create
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has_role {
|
||||
return ErrPermissionDeniedNeedRoleMapfixReview
|
||||
}
|
||||
|
||||
userId, err := userInfo.GetUserID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !has_role {
|
||||
// Submitter has special permission to comment on their mapfix
|
||||
mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mapfix.Submitter != userId {
|
||||
return ErrPermissionDeniedNeedRoleMapfixReview
|
||||
}
|
||||
}
|
||||
|
||||
data := []byte{}
|
||||
_, err = req.Read(data)
|
||||
if err != nil {
|
||||
@@ -146,15 +155,24 @@ func (svc *Service) CreateSubmissionAuditComment(ctx context.Context, req api.Cr
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has_role {
|
||||
return ErrPermissionDeniedNeedRoleSubmissionReview
|
||||
}
|
||||
|
||||
userId, err := userInfo.GetUserID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !has_role {
|
||||
// Submitter has special permission to comment on their submission
|
||||
submission, err := svc.DB.Submissions().Get(ctx, params.SubmissionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if submission.Submitter != userId {
|
||||
return ErrPermissionDeniedNeedRoleSubmissionReview
|
||||
}
|
||||
}
|
||||
|
||||
data := []byte{}
|
||||
_, err = req.Read(data)
|
||||
if err != nil {
|
||||
|
||||
@@ -396,10 +396,42 @@ func (svc *Service) ActionMapfixRequestChanges(ctx context.Context, params api.A
|
||||
return ErrPermissionDeniedNeedRoleMapfixReview
|
||||
}
|
||||
|
||||
userId, err := userInfo.GetUserID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// transaction
|
||||
target_status := model.MapfixStatusChangesRequested
|
||||
smap := datastore.Optional()
|
||||
smap.Add("status_id", model.MapfixStatusChangesRequested)
|
||||
return svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidated, model.MapfixStatusAcceptedUnvalidated, model.MapfixStatusSubmitted}, smap)
|
||||
smap.Add("status_id", target_status)
|
||||
err = svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusValidated, model.MapfixStatusAcceptedUnvalidated, model.MapfixStatusSubmitted}, smap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event_data := model.AuditEventDataAction{
|
||||
TargetStatus: uint32(target_status),
|
||||
}
|
||||
|
||||
EventData, err := json.Marshal(event_data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = svc.DB.AuditEvents().Create(ctx, model.AuditEvent{
|
||||
ID: 0,
|
||||
User: userId,
|
||||
ResourceType: model.ResourceMapfix,
|
||||
ResourceID: params.MapfixID,
|
||||
EventType: model.AuditEventTypeAction,
|
||||
EventData: EventData,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActionMapfixRevoke invokes actionMapfixRevoke operation.
|
||||
@@ -539,6 +571,76 @@ func (svc *Service) ActionMapfixTriggerSubmit(ctx context.Context, params api.Ac
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActionMapfixBypassSubmit invokes actionMapfixBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /mapfixes/{MapfixID}/status/bypass-submit
|
||||
func (svc *Service) ActionMapfixBypassSubmit(ctx context.Context, params api.ActionMapfixBypassSubmitParams) error {
|
||||
userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
|
||||
if !ok {
|
||||
return ErrUserInfo
|
||||
}
|
||||
|
||||
// read mapfix (this could be done with a transaction WHERE clause)
|
||||
mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userId, err := userInfo.GetUserID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if caller is the submitter
|
||||
is_submitter := userId == mapfix.Submitter
|
||||
if is_submitter {
|
||||
return ErrAcceptOwnMapfix
|
||||
}
|
||||
|
||||
has_mapfix_review, err := userInfo.HasRoleMapfixReview()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !has_mapfix_review {
|
||||
return ErrPermissionDeniedNeedRoleMapfixReview
|
||||
}
|
||||
|
||||
// transaction
|
||||
target_status := model.MapfixStatusSubmitted
|
||||
smap := datastore.Optional()
|
||||
smap.Add("status_id", target_status)
|
||||
err = svc.DB.Mapfixes().IfStatusThenUpdate(ctx, params.MapfixID, []model.MapfixStatus{model.MapfixStatusChangesRequested}, smap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event_data := model.AuditEventDataAction{
|
||||
TargetStatus: uint32(target_status),
|
||||
}
|
||||
|
||||
EventData, err := json.Marshal(event_data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = svc.DB.AuditEvents().Create(ctx, model.AuditEvent{
|
||||
ID: 0,
|
||||
User: userId,
|
||||
ResourceType: model.ResourceMapfix,
|
||||
ResourceID: params.MapfixID,
|
||||
EventType: model.AuditEventTypeAction,
|
||||
EventData: EventData,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActionMapfixResetSubmitting implements actionMapfixResetSubmitting operation.
|
||||
//
|
||||
// Role MapfixReview changes status from Submitting -> UnderConstruction.
|
||||
|
||||
@@ -20,7 +20,7 @@ var(
|
||||
model.SubmissionStatusSubmitted,
|
||||
model.SubmissionStatusUnderConstruction,
|
||||
}
|
||||
// limit mapfixes in the pipeline to one per target map
|
||||
// limit submissions in the pipeline to one per target map
|
||||
ActiveAcceptedSubmissionStatuses = []model.SubmissionStatus{
|
||||
model.SubmissionStatusUploading,
|
||||
model.SubmissionStatusValidated,
|
||||
@@ -584,16 +584,18 @@ func (svc *Service) ActionSubmissionTriggerSubmit(ctx context.Context, params ap
|
||||
return err
|
||||
}
|
||||
|
||||
has_submission_review, err := userInfo.HasRoleSubmissionReview()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if caller is the submitter
|
||||
is_submitter := userId == submission.Submitter
|
||||
// neither = deny
|
||||
if !is_submitter && !has_submission_review {
|
||||
return ErrPermissionDeniedNotSubmitter
|
||||
if !is_submitter {
|
||||
has_submission_review, err := userInfo.HasRoleSubmissionReview()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !has_submission_review {
|
||||
return ErrPermissionDeniedNotSubmitter
|
||||
}
|
||||
}
|
||||
|
||||
// transaction
|
||||
@@ -644,6 +646,76 @@ func (svc *Service) ActionSubmissionTriggerSubmit(ctx context.Context, params ap
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActionSubmissionBypassSubmit invokes actionSubmissionBypassSubmit operation.
|
||||
//
|
||||
// Role Reviewer changes status from ChangesRequested -> Submitted.
|
||||
//
|
||||
// POST /submissions/{SubmissionID}/status/bypass-submit
|
||||
func (svc *Service) ActionSubmissionBypassSubmit(ctx context.Context, params api.ActionSubmissionBypassSubmitParams) error {
|
||||
userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle)
|
||||
if !ok {
|
||||
return ErrUserInfo
|
||||
}
|
||||
|
||||
// read submission (this could be done with a transaction WHERE clause)
|
||||
submission, err := svc.DB.Submissions().Get(ctx, params.SubmissionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userId, err := userInfo.GetUserID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if caller is the submitter
|
||||
is_submitter := userId == submission.Submitter
|
||||
if is_submitter {
|
||||
return ErrAcceptOwnSubmission
|
||||
}
|
||||
|
||||
has_submission_review, err := userInfo.HasRoleSubmissionReview()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !has_submission_review {
|
||||
return ErrPermissionDeniedNeedRoleSubmissionReview
|
||||
}
|
||||
|
||||
// transaction
|
||||
target_status := model.SubmissionStatusSubmitted
|
||||
smap := datastore.Optional()
|
||||
smap.Add("status_id", target_status)
|
||||
err = svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusChangesRequested}, smap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event_data := model.AuditEventDataAction{
|
||||
TargetStatus: uint32(target_status),
|
||||
}
|
||||
|
||||
EventData, err := json.Marshal(event_data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = svc.DB.AuditEvents().Create(ctx, model.AuditEvent{
|
||||
ID: 0,
|
||||
User: userId,
|
||||
ResourceType: model.ResourceSubmission,
|
||||
ResourceID: params.SubmissionID,
|
||||
EventType: model.AuditEventTypeAction,
|
||||
EventData: EventData,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActionSubmissionResetSubmitting implements actionSubmissionResetSubmitting operation.
|
||||
//
|
||||
// Role SubmissionReview changes status from Submitting -> UnderConstruction.
|
||||
|
||||
@@ -5,13 +5,13 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
submissions-api = { path = "api", features = ["internal"], default-features = false, registry = "strafesnet" }
|
||||
async-nats = "0.40.0"
|
||||
async-nats = "0.41.0"
|
||||
futures = "0.3.31"
|
||||
rbx_asset = { version = "0.4.4-pre2", registry = "strafesnet" }
|
||||
rbx_binary = { version = "0.7.4", registry = "strafesnet"}
|
||||
rbx_dom_weak = { version = "2.9.0", registry = "strafesnet"}
|
||||
rbx_reflection_database = { version = "0.2.12", registry = "strafesnet"}
|
||||
rbx_xml = { version = "0.13.3", registry = "strafesnet"}
|
||||
rbx_asset = { version = "0.4.5", registry = "strafesnet" }
|
||||
rbx_binary = "1.0.0"
|
||||
rbx_dom_weak = "3.0.0"
|
||||
rbx_reflection_database = "1.0.3"
|
||||
rbx_xml = "1.0.0"
|
||||
serde = { version = "1.0.215", features = ["derive"] }
|
||||
serde_json = "1.0.133"
|
||||
siphasher = "1.0.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "submissions-api"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
publish = ["strafesnet"]
|
||||
repository = "https://git.itzana.me/StrafesNET/maps-service"
|
||||
|
||||
@@ -44,4 +44,8 @@ impl Context{
|
||||
.body(body)
|
||||
.send().await
|
||||
}
|
||||
pub async fn delete(&self,url:impl reqwest::IntoUrl)->Result<reqwest::Response,reqwest::Error>{
|
||||
self.client.delete(url)
|
||||
.send().await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_scripts<'a>(&self,config:GetScriptsRequest<'a>)->Result<Vec<ScriptResponse>,Error>{
|
||||
pub async fn get_scripts(&self,config:GetScriptsRequest<'_>)->Result<Vec<ScriptResponse>,Error>{
|
||||
let url_raw=format!("{}/scripts",self.0.base_url);
|
||||
let mut url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
|
||||
|
||||
@@ -46,7 +46,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_script_from_hash<'a>(&self,config:HashRequest<'a>)->Result<Option<ScriptResponse>,SingleItemError>{
|
||||
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,ScriptSingleItemError>{
|
||||
let scripts=self.get_scripts(GetScriptsRequest{
|
||||
Page:1,
|
||||
Limit:2,
|
||||
@@ -57,11 +57,11 @@ impl Context{
|
||||
ResourceID:None,
|
||||
}).await.map_err(SingleItemError::Other)?;
|
||||
if 1<scripts.len(){
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
return Err(SingleItemError::DuplicateItems(scripts));
|
||||
}
|
||||
Ok(scripts.into_iter().next())
|
||||
}
|
||||
pub async fn create_script<'a>(&self,config:CreateScriptRequest<'a>)->Result<ScriptIDResponse,Error>{
|
||||
pub async fn create_script(&self,config:CreateScriptRequest<'_>)->Result<ScriptIDResponse,Error>{
|
||||
let url_raw=format!("{}/scripts",self.0.base_url);
|
||||
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
|
||||
|
||||
@@ -72,7 +72,17 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_script_policies<'a>(&self,config:GetScriptPoliciesRequest<'a>)->Result<Vec<ScriptPolicyResponse>,Error>{
|
||||
pub async fn delete_script(&self,config:GetScriptRequest)->Result<(),Error>{
|
||||
let url_raw=format!("{}/scripts/{}",self.0.base_url,config.ScriptID.0);
|
||||
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
|
||||
|
||||
response_ok(
|
||||
self.0.delete(url).await.map_err(Error::Reqwest)?
|
||||
).await.map_err(Error::Response)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
pub async fn get_script_policies(&self,config:GetScriptPoliciesRequest<'_>)->Result<Vec<ScriptPolicyResponse>,Error>{
|
||||
let url_raw=format!("{}/script-policy",self.0.base_url);
|
||||
let mut url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
|
||||
|
||||
@@ -96,7 +106,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_script_policy_from_hash<'a>(&self,config:HashRequest<'a>)->Result<Option<ScriptPolicyResponse>,SingleItemError>{
|
||||
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,ScriptPolicySingleItemError>{
|
||||
let policies=self.get_script_policies(GetScriptPoliciesRequest{
|
||||
Page:1,
|
||||
Limit:2,
|
||||
@@ -105,7 +115,7 @@ impl Context{
|
||||
Policy:None,
|
||||
}).await.map_err(SingleItemError::Other)?;
|
||||
if 1<policies.len(){
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
return Err(SingleItemError::DuplicateItems(policies));
|
||||
}
|
||||
Ok(policies.into_iter().next())
|
||||
}
|
||||
@@ -130,6 +140,16 @@ impl Context{
|
||||
self.0.post(url,body).await.map_err(Error::Reqwest)?
|
||||
).await.map_err(Error::Response)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
pub async fn delete_script_policy(&self,config:GetScriptPolicyRequest)->Result<(),Error>{
|
||||
let url_raw=format!("{}/script-policy/{}",self.0.base_url,config.ScriptPolicyID.0);
|
||||
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
|
||||
|
||||
response_ok(
|
||||
self.0.delete(url).await.map_err(Error::Reqwest)?
|
||||
).await.map_err(Error::Response)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_scripts<'a>(&self,config:GetScriptsRequest<'a>)->Result<Vec<ScriptResponse>,Error>{
|
||||
pub async fn get_scripts(&self,config:GetScriptsRequest<'_>)->Result<Vec<ScriptResponse>,Error>{
|
||||
let url_raw=format!("{}/scripts",self.0.base_url);
|
||||
let mut url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
|
||||
|
||||
@@ -76,7 +76,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_script_from_hash<'a>(&self,config:HashRequest<'a>)->Result<Option<ScriptResponse>,SingleItemError>{
|
||||
pub async fn get_script_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptResponse>,ScriptSingleItemError>{
|
||||
let scripts=self.get_scripts(GetScriptsRequest{
|
||||
Page:1,
|
||||
Limit:2,
|
||||
@@ -87,11 +87,11 @@ impl Context{
|
||||
ResourceID:None,
|
||||
}).await.map_err(SingleItemError::Other)?;
|
||||
if 1<scripts.len(){
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
return Err(SingleItemError::DuplicateItems(scripts));
|
||||
}
|
||||
Ok(scripts.into_iter().next())
|
||||
}
|
||||
pub async fn create_script<'a>(&self,config:CreateScriptRequest<'a>)->Result<ScriptIDResponse,Error>{
|
||||
pub async fn create_script(&self,config:CreateScriptRequest<'_>)->Result<ScriptIDResponse,Error>{
|
||||
let url_raw=format!("{}/scripts",self.0.base_url);
|
||||
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
|
||||
|
||||
@@ -102,7 +102,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_script_policies<'a>(&self,config:GetScriptPoliciesRequest<'a>)->Result<Vec<ScriptPolicyResponse>,Error>{
|
||||
pub async fn get_script_policies(&self,config:GetScriptPoliciesRequest<'_>)->Result<Vec<ScriptPolicyResponse>,Error>{
|
||||
let url_raw=format!("{}/script-policy",self.0.base_url);
|
||||
let mut url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
|
||||
|
||||
@@ -126,7 +126,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn get_script_policy_from_hash<'a>(&self,config:HashRequest<'a>)->Result<Option<ScriptPolicyResponse>,SingleItemError>{
|
||||
pub async fn get_script_policy_from_hash(&self,config:HashRequest<'_>)->Result<Option<ScriptPolicyResponse>,ScriptPolicySingleItemError>{
|
||||
let policies=self.get_script_policies(GetScriptPoliciesRequest{
|
||||
Page:1,
|
||||
Limit:2,
|
||||
@@ -135,7 +135,7 @@ impl Context{
|
||||
Policy:None,
|
||||
}).await.map_err(SingleItemError::Other)?;
|
||||
if 1<policies.len(){
|
||||
return Err(SingleItemError::DuplicateItems);
|
||||
return Err(SingleItemError::DuplicateItems(policies));
|
||||
}
|
||||
Ok(policies.into_iter().next())
|
||||
}
|
||||
@@ -150,7 +150,7 @@ impl Context{
|
||||
).await.map_err(Error::Response)?
|
||||
.json().await.map_err(Error::ReqwestJson)
|
||||
}
|
||||
pub async fn create_submission<'a>(&self,config:CreateSubmissionRequest<'a>)->Result<SubmissionIDResponse,Error>{
|
||||
pub async fn create_submission(&self,config:CreateSubmissionRequest<'_>)->Result<SubmissionIDResponse,Error>{
|
||||
let url_raw=format!("{}/submissions",self.0.base_url);
|
||||
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
|
||||
|
||||
@@ -182,7 +182,7 @@ impl Context{
|
||||
action!("submissions",action_submission_accepted,config,ActionSubmissionAcceptedRequest,"status/validator-failed",config.SubmissionID,
|
||||
("ErrorMessage",config.ErrorMessage.as_str())
|
||||
);
|
||||
pub async fn create_mapfix<'a>(&self,config:CreateMapfixRequest<'a>)->Result<MapfixIDResponse,Error>{
|
||||
pub async fn create_mapfix(&self,config:CreateMapfixRequest<'_>)->Result<MapfixIDResponse,Error>{
|
||||
let url_raw=format!("{}/mapfixes",self.0.base_url);
|
||||
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;
|
||||
|
||||
|
||||
@@ -14,21 +14,25 @@ impl std::fmt::Display for Error{
|
||||
impl std::error::Error for Error{}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SingleItemError{
|
||||
DuplicateItems,
|
||||
pub enum SingleItemError<Items>{
|
||||
DuplicateItems(Items),
|
||||
Other(Error),
|
||||
}
|
||||
impl std::fmt::Display for SingleItemError{
|
||||
impl<Items> std::fmt::Display for SingleItemError<Items>
|
||||
where
|
||||
Items:std::fmt::Debug
|
||||
{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
write!(f,"{self:?}")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SingleItemError{}
|
||||
impl<Items> std::error::Error for SingleItemError<Items> where Items:std::fmt::Debug{}
|
||||
pub type ScriptSingleItemError=SingleItemError<Vec<ScriptResponse>>;
|
||||
pub type ScriptPolicySingleItemError=SingleItemError<Vec<ScriptPolicyResponse>>;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct StatusCodeWithUrlAndBody{
|
||||
pub status_code:reqwest::StatusCode,
|
||||
pub struct UrlAndBody{
|
||||
pub url:url::Url,
|
||||
pub body:String,
|
||||
}
|
||||
@@ -36,7 +40,10 @@ pub struct StatusCodeWithUrlAndBody{
|
||||
#[derive(Debug)]
|
||||
pub enum ResponseError{
|
||||
Reqwest(reqwest::Error),
|
||||
StatusCodeWithUrlAndBody(StatusCodeWithUrlAndBody),
|
||||
Details{
|
||||
status_code:reqwest::StatusCode,
|
||||
url_and_body:Box<UrlAndBody>,
|
||||
},
|
||||
}
|
||||
impl std::fmt::Display for ResponseError{
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -53,11 +60,10 @@ pub async fn response_ok(response:reqwest::Response)->Result<reqwest::Response,R
|
||||
let url=response.url().to_owned();
|
||||
let bytes=response.bytes().await.map_err(ResponseError::Reqwest)?;
|
||||
let body=String::from_utf8_lossy(&bytes).to_string();
|
||||
Err(ResponseError::StatusCodeWithUrlAndBody(StatusCodeWithUrlAndBody{
|
||||
Err(ResponseError::Details{
|
||||
status_code,
|
||||
url,
|
||||
body,
|
||||
}))
|
||||
url_and_body:Box::new(UrlAndBody{url,body})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +178,10 @@ pub enum Policy{
|
||||
Replace=4,
|
||||
}
|
||||
|
||||
#[allow(nonstandard_style)]
|
||||
pub struct GetScriptPolicyRequest{
|
||||
pub ScriptPolicyID:ScriptPolicyID,
|
||||
}
|
||||
#[allow(nonstandard_style)]
|
||||
#[derive(Clone,Debug,serde::Serialize)]
|
||||
pub struct GetScriptPoliciesRequest<'a>{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::{HashSet,HashMap};
|
||||
use crate::download::download_asset_version;
|
||||
use crate::rbx_util::{class_is_a,get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
|
||||
use crate::rbx_util::{get_mapinfo,get_root_instance,read_dom,ReadDomError,GameID,ParseGameIDError,MapInfo,GetRootInstanceError,StringValueError};
|
||||
|
||||
use heck::{ToSnakeCase,ToTitleCase};
|
||||
|
||||
@@ -12,6 +12,7 @@ pub enum Error{
|
||||
Download(crate::download::Error),
|
||||
ModelFileDecode(ReadDomError),
|
||||
GetRootInstance(GetRootInstanceError),
|
||||
ToJsonValue(serde_json::Error),
|
||||
}
|
||||
impl std::fmt::Display for Error{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
@@ -47,98 +48,166 @@ impl ModeID{
|
||||
const BONUS:Self=Self(1);
|
||||
}
|
||||
enum Zone{
|
||||
Start(ModeID),
|
||||
Finish(ModeID),
|
||||
Anticheat(ModeID),
|
||||
Start,
|
||||
Finish,
|
||||
Anticheat,
|
||||
}
|
||||
struct ModeElement{
|
||||
zone:Zone,
|
||||
mode_id:ModeID,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub enum IDParseError{
|
||||
NoCaptures,
|
||||
ParseInt(core::num::ParseIntError)
|
||||
ParseInt(core::num::ParseIntError),
|
||||
}
|
||||
impl std::str::FromStr for Zone{
|
||||
// Parse a Zone from a part name
|
||||
impl std::str::FromStr for ModeElement{
|
||||
type Err=IDParseError;
|
||||
fn from_str(s:&str)->Result<Self,Self::Err>{
|
||||
match s{
|
||||
"MapStart"=>Ok(Self::Start(ModeID::MAIN)),
|
||||
"MapFinish"=>Ok(Self::Finish(ModeID::MAIN)),
|
||||
"MapAnticheat"=>Ok(Self::Anticheat(ModeID::MAIN)),
|
||||
"BonusStart"=>Ok(Self::Start(ModeID::BONUS)),
|
||||
"BonusFinish"=>Ok(Self::Finish(ModeID::BONUS)),
|
||||
"BonusAnticheat"=>Ok(Self::Anticheat(ModeID::BONUS)),
|
||||
"MapStart"=>Ok(Self{zone:Zone::Start,mode_id:ModeID::MAIN}),
|
||||
"MapFinish"=>Ok(Self{zone:Zone::Finish,mode_id:ModeID::MAIN}),
|
||||
"MapAnticheat"=>Ok(Self{zone:Zone::Anticheat,mode_id:ModeID::MAIN}),
|
||||
"BonusStart"=>Ok(Self{zone:Zone::Start,mode_id:ModeID::BONUS}),
|
||||
"BonusFinish"=>Ok(Self{zone:Zone::Finish,mode_id:ModeID::BONUS}),
|
||||
"BonusAnticheat"=>Ok(Self{zone:Zone::Anticheat,mode_id:ModeID::BONUS}),
|
||||
other=>{
|
||||
let bonus_start_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Start$|^BonusStart(\d+)$");
|
||||
if let Some(captures)=bonus_start_pattern.captures(other){
|
||||
return Ok(Self::Start(ModeID(captures[1].parse().map_err(IDParseError::ParseInt)?)));
|
||||
}
|
||||
let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Finish$|^BonusFinish(\d+)$");
|
||||
if let Some(captures)=bonus_finish_pattern.captures(other){
|
||||
return Ok(Self::Finish(ModeID(captures[1].parse().map_err(IDParseError::ParseInt)?)));
|
||||
}
|
||||
let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Anticheat$|^BonusAnticheat(\d+)$");
|
||||
if let Some(captures)=bonus_finish_pattern.captures(other){
|
||||
return Ok(Self::Anticheat(ModeID(captures[1].parse().map_err(IDParseError::ParseInt)?)));
|
||||
let everything_pattern=lazy_regex::lazy_regex!(r"^Bonus(\d+)Start$|^BonusStart(\d+)$|^Bonus(\d+)Finish$|^BonusFinish(\d+)$|^Bonus(\d+)Anticheat$|^BonusAnticheat(\d+)$");
|
||||
if let Some(captures)=everything_pattern.captures(other){
|
||||
if let Some(mode_id)=captures.get(1).or(captures.get(2)){
|
||||
return Ok(Self{
|
||||
zone:Zone::Start,
|
||||
mode_id:ModeID(mode_id.as_str().parse().map_err(IDParseError::ParseInt)?),
|
||||
});
|
||||
}
|
||||
if let Some(mode_id)=captures.get(3).or(captures.get(4)){
|
||||
return Ok(Self{
|
||||
zone:Zone::Finish,
|
||||
mode_id:ModeID(mode_id.as_str().parse().map_err(IDParseError::ParseInt)?),
|
||||
});
|
||||
}
|
||||
if let Some(mode_id)=captures.get(5).or(captures.get(6)){
|
||||
return Ok(Self{
|
||||
zone:Zone::Anticheat,
|
||||
mode_id:ModeID(mode_id.as_str().parse().map_err(IDParseError::ParseInt)?),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(IDParseError::NoCaptures)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for ModeElement{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
match self{
|
||||
ModeElement{zone:Zone::Start,mode_id:ModeID::MAIN}=>write!(f,"MapStart"),
|
||||
ModeElement{zone:Zone::Start,mode_id:ModeID::BONUS}=>write!(f,"BonusStart"),
|
||||
ModeElement{zone:Zone::Start,mode_id:ModeID(mode_id)}=>write!(f,"Bonus{mode_id}Start"),
|
||||
ModeElement{zone:Zone::Finish,mode_id:ModeID::MAIN}=>write!(f,"MapFinish"),
|
||||
ModeElement{zone:Zone::Finish,mode_id:ModeID::BONUS}=>write!(f,"BonusFinish"),
|
||||
ModeElement{zone:Zone::Finish,mode_id:ModeID(mode_id)}=>write!(f,"Bonus{mode_id}Finish"),
|
||||
ModeElement{zone:Zone::Anticheat,mode_id:ModeID::MAIN}=>write!(f,"MapAnticheat"),
|
||||
ModeElement{zone:Zone::Anticheat,mode_id:ModeID::BONUS}=>write!(f,"BonusAnticheat"),
|
||||
ModeElement{zone:Zone::Anticheat,mode_id:ModeID(mode_id)}=>write!(f,"Bonus{mode_id}Anticheat"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)]
|
||||
struct SpawnID(u64);
|
||||
impl SpawnID{
|
||||
struct StageID(u64);
|
||||
impl StageID{
|
||||
const FIRST:Self=Self(1);
|
||||
}
|
||||
enum SpawnTeleport{
|
||||
Teleport(SpawnID),
|
||||
Spawn(SpawnID),
|
||||
enum StageElementBehaviour{
|
||||
Teleport,
|
||||
Spawn,
|
||||
}
|
||||
impl std::str::FromStr for SpawnTeleport{
|
||||
struct StageElement{
|
||||
stage_id:StageID,
|
||||
behaviour:StageElementBehaviour,
|
||||
}
|
||||
// Parse a SpawnTeleport from a part name
|
||||
impl std::str::FromStr for StageElement{
|
||||
type Err=IDParseError;
|
||||
fn from_str(s:&str)->Result<Self,Self::Err>{
|
||||
// Trigger ForceTrigger Teleport ForceTeleport SpawnAt ForceSpawnAt
|
||||
let bonus_start_pattern=lazy_regex::lazy_regex!(r"^(?:Force)?(Teleport|SpawnAt|Trigger)(\d+)$");
|
||||
if let Some(captures)=bonus_start_pattern.captures(s){
|
||||
return Ok(Self::Teleport(SpawnID(captures[1].parse().map_err(IDParseError::ParseInt)?)));
|
||||
return Ok(StageElement{
|
||||
behaviour:StageElementBehaviour::Teleport,
|
||||
stage_id:StageID(captures[1].parse().map_err(IDParseError::ParseInt)?),
|
||||
});
|
||||
}
|
||||
// Spawn
|
||||
let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^Spawn(\d+)$");
|
||||
if let Some(captures)=bonus_finish_pattern.captures(s){
|
||||
return Ok(Self::Spawn(SpawnID(captures[1].parse().map_err(IDParseError::ParseInt)?)));
|
||||
return Ok(StageElement{
|
||||
behaviour:StageElementBehaviour::Spawn,
|
||||
stage_id:StageID(captures[1].parse().map_err(IDParseError::ParseInt)?),
|
||||
});
|
||||
}
|
||||
Err(IDParseError::NoCaptures)
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for StageElement{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
match self{
|
||||
StageElement{behaviour:StageElementBehaviour::Spawn,stage_id:StageID(stage_id)}=>write!(f,"Spawn{stage_id}"),
|
||||
StageElement{behaviour:StageElementBehaviour::Teleport,stage_id:StageID(stage_id)}=>write!(f,"Teleport{stage_id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)]
|
||||
struct WormholeID(u64);
|
||||
enum Wormhole{
|
||||
In(WormholeID),
|
||||
Out(WormholeID),
|
||||
enum WormholeBehaviour{
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
impl std::str::FromStr for Wormhole{
|
||||
struct WormholeElement{
|
||||
behaviour:WormholeBehaviour,
|
||||
wormhole_id:WormholeID,
|
||||
}
|
||||
// Parse a Wormhole from a part name
|
||||
impl std::str::FromStr for WormholeElement{
|
||||
type Err=IDParseError;
|
||||
fn from_str(s:&str)->Result<Self,Self::Err>{
|
||||
let bonus_start_pattern=lazy_regex::lazy_regex!(r"^WormholeIn(\d+)$");
|
||||
if let Some(captures)=bonus_start_pattern.captures(s){
|
||||
return Ok(Self::In(WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?)));
|
||||
return Ok(Self{
|
||||
behaviour:WormholeBehaviour::In,
|
||||
wormhole_id:WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?),
|
||||
});
|
||||
}
|
||||
let bonus_finish_pattern=lazy_regex::lazy_regex!(r"^WormholeOut(\d+)$");
|
||||
if let Some(captures)=bonus_finish_pattern.captures(s){
|
||||
return Ok(Self::Out(WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?)));
|
||||
return Ok(Self{
|
||||
behaviour:WormholeBehaviour::Out,
|
||||
wormhole_id:WormholeID(captures[1].parse().map_err(IDParseError::ParseInt)?),
|
||||
});
|
||||
}
|
||||
Err(IDParseError::NoCaptures)
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for WormholeElement{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
match self{
|
||||
WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id:WormholeID(wormhole_id)}=>write!(f,"WormholeIn{wormhole_id}"),
|
||||
WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id:WormholeID(wormhole_id)}=>write!(f,"WormholeOut{wormhole_id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Count various map elements
|
||||
#[derive(Default)]
|
||||
struct Counts<'a>{
|
||||
mode_start_counts:HashMap<ModeID,Vec<&'a str>>,
|
||||
mode_finish_counts:HashMap<ModeID,Vec<&'a str>>,
|
||||
mode_anticheat_counts:HashMap<ModeID,Vec<&'a str>>,
|
||||
teleport_counts:HashMap<SpawnID,Vec<&'a str>>,
|
||||
spawn_counts:HashMap<SpawnID,u64>,
|
||||
teleport_counts:HashMap<StageID,Vec<&'a str>>,
|
||||
spawn_counts:HashMap<StageID,u64>,
|
||||
wormhole_in_counts:HashMap<WormholeID,u64>,
|
||||
wormhole_out_counts:HashMap<WormholeID,u64>,
|
||||
}
|
||||
@@ -152,31 +221,37 @@ pub struct ModelInfo<'a>{
|
||||
|
||||
pub fn get_model_info<'a>(dom:&'a rbx_dom_weak::WeakDom,model_instance:&'a rbx_dom_weak::Instance)->ModelInfo<'a>{
|
||||
// extract model info
|
||||
let map_info=get_mapinfo(&dom,model_instance);
|
||||
let map_info=get_mapinfo(dom,model_instance);
|
||||
|
||||
// count objects (default count is 0)
|
||||
let mut counts=Counts::default();
|
||||
for instance in dom.descendants_of(model_instance.referent()){
|
||||
if class_is_a(instance.class.as_str(),"BasePart"){
|
||||
// Zones
|
||||
match instance.name.parse(){
|
||||
Ok(Zone::Start(mode_id))=>counts.mode_start_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Ok(Zone::Finish(mode_id))=>counts.mode_finish_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Ok(Zone::Anticheat(mode_id))=>counts.mode_anticheat_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Err(_)=>(),
|
||||
}
|
||||
// Spawns & Teleports
|
||||
match instance.name.parse(){
|
||||
Ok(SpawnTeleport::Teleport(spawn_id))=>counts.teleport_counts.entry(spawn_id).or_default().push(instance.name.as_str()),
|
||||
Ok(SpawnTeleport::Spawn(spawn_id))=>*counts.spawn_counts.entry(spawn_id).or_insert(0)+=1,
|
||||
Err(_)=>(),
|
||||
}
|
||||
// Wormholes
|
||||
match instance.name.parse(){
|
||||
Ok(Wormhole::In(wormhole_id))=>*counts.wormhole_in_counts.entry(wormhole_id).or_insert(0)+=1,
|
||||
Ok(Wormhole::Out(wormhole_id))=>*counts.wormhole_out_counts.entry(wormhole_id).or_insert(0)+=1,
|
||||
Err(_)=>(),
|
||||
}
|
||||
|
||||
let db=rbx_reflection_database::get();
|
||||
let base_part=&db.classes["BasePart"];
|
||||
let base_parts=dom.descendants_of(model_instance.referent()).filter(|&instance|
|
||||
db.classes.get(instance.class.as_str()).is_some_and(|class|
|
||||
db.has_superclass(class,base_part)
|
||||
)
|
||||
);
|
||||
for instance in base_parts{
|
||||
// Zones
|
||||
match instance.name.parse(){
|
||||
Ok(ModeElement{zone:Zone::Start,mode_id})=>counts.mode_start_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Ok(ModeElement{zone:Zone::Finish,mode_id})=>counts.mode_finish_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Ok(ModeElement{zone:Zone::Anticheat,mode_id})=>counts.mode_anticheat_counts.entry(mode_id).or_default().push(instance.name.as_str()),
|
||||
Err(_)=>(),
|
||||
}
|
||||
// Spawns & Teleports
|
||||
match instance.name.parse(){
|
||||
Ok(StageElement{behaviour:StageElementBehaviour::Teleport,stage_id})=>counts.teleport_counts.entry(stage_id).or_default().push(instance.name.as_str()),
|
||||
Ok(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id})=>*counts.spawn_counts.entry(stage_id).or_insert(0)+=1,
|
||||
Err(_)=>(),
|
||||
}
|
||||
// Wormholes
|
||||
match instance.name.parse(){
|
||||
Ok(WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id})=>*counts.wormhole_in_counts.entry(wormhole_id).or_insert(0)+=1,
|
||||
Ok(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id})=>*counts.wormhole_out_counts.entry(wormhole_id).or_insert(0)+=1,
|
||||
Err(_)=>(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +273,7 @@ impl<'a,Str> StringCheckContext<'a,Str>
|
||||
where
|
||||
&'a str:PartialEq<Str>,
|
||||
{
|
||||
/// Compute the StringCheck, passing through the provided value on success.
|
||||
fn check<T>(self,value:T)->StringCheck<'a,T,Str>{
|
||||
if self.observed==self.expected{
|
||||
StringCheck(Ok(value))
|
||||
@@ -206,7 +282,7 @@ impl<'a,Str> StringCheckContext<'a,Str>
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a,Str:std::fmt::Display> std::fmt::Display for StringCheckContext<'a,Str>{
|
||||
impl<Str:std::fmt::Display> std::fmt::Display for StringCheckContext<'_,Str>{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
write!(f,"expected: {}, observed: {}",self.expected,self.observed)
|
||||
}
|
||||
@@ -227,6 +303,7 @@ fn check_empty(value:&str)->Result<&str,StringEmpty>{
|
||||
pub struct DuplicateCheckContext<ID,T>(HashMap<ID,T>);
|
||||
pub struct DuplicateCheck<ID,T>(Result<(),DuplicateCheckContext<ID,T>>);
|
||||
impl<ID,T> DuplicateCheckContext<ID,T>{
|
||||
/// Compute the DuplicateCheck using the contents predicate.
|
||||
fn check(self,f:impl Fn(&T)->bool)->DuplicateCheck<ID,T>{
|
||||
let Self(mut set)=self;
|
||||
// remove correct entries
|
||||
@@ -240,10 +317,11 @@ impl<ID,T> DuplicateCheckContext<ID,T>{
|
||||
}
|
||||
}
|
||||
|
||||
// check that there is at least one matching item for each item in a reference set, and no extra items
|
||||
// Check that there are no items which do not have a matching item in a reference set
|
||||
pub struct SetDifferenceCheckContextAllowNone<ID,T>{
|
||||
extra:HashMap<ID,T>,
|
||||
}
|
||||
// Check that there is at least one matching item for each item in a reference set, and no extra items
|
||||
pub struct SetDifferenceCheckContextAtLeastOne<ID,T>{
|
||||
extra:HashMap<ID,T>,
|
||||
missing:HashSet<ID>,
|
||||
@@ -257,9 +335,10 @@ impl<ID,T> SetDifferenceCheckContextAllowNone<ID,T>{
|
||||
}
|
||||
}
|
||||
impl<ID:Eq+std::hash::Hash,T> SetDifferenceCheckContextAllowNone<ID,T>{
|
||||
/// Compute the SetDifferenceCheck result for the specified reference set.
|
||||
fn check<U>(mut self,reference_set:&HashMap<ID,U>)->SetDifferenceCheck<Self>{
|
||||
// remove correct entries
|
||||
for (id,_) in reference_set{
|
||||
for id in reference_set.keys(){
|
||||
self.extra.remove(id);
|
||||
}
|
||||
// if any entries remain, they are incorrect
|
||||
@@ -279,9 +358,10 @@ impl<ID,T> SetDifferenceCheckContextAtLeastOne<ID,T>{
|
||||
}
|
||||
}
|
||||
impl<ID:Copy+Eq+std::hash::Hash,T> SetDifferenceCheckContextAtLeastOne<ID,T>{
|
||||
/// Compute the SetDifferenceCheck result for the specified reference set.
|
||||
fn check<U>(mut self,reference_set:&HashMap<ID,U>)->SetDifferenceCheck<Self>{
|
||||
// remove correct entries
|
||||
for (id,_) in reference_set{
|
||||
for id in reference_set.keys(){
|
||||
if self.extra.remove(id).is_none(){
|
||||
// the set did not contain a required item. This is a fail
|
||||
self.missing.insert(*id);
|
||||
@@ -296,14 +376,19 @@ impl<ID:Copy+Eq+std::hash::Hash,T> SetDifferenceCheckContextAtLeastOne<ID,T>{
|
||||
}
|
||||
}
|
||||
|
||||
/// Info lifted out of a fully compliant map
|
||||
pub struct MapInfoOwned{
|
||||
pub display_name:String,
|
||||
pub creator:String,
|
||||
pub game_id:GameID,
|
||||
}
|
||||
|
||||
// crazy!
|
||||
pub struct MapCheck<'a>{
|
||||
// Named dummy types for readability
|
||||
struct Exists;
|
||||
struct Absent;
|
||||
|
||||
/// The result of every map check.
|
||||
struct MapCheck<'a>{
|
||||
// === METADATA CHECKS ===
|
||||
// The root must be of class Model
|
||||
model_class:StringCheck<'a,(),&'static str>,
|
||||
@@ -321,7 +406,7 @@ pub struct MapCheck<'a>{
|
||||
|
||||
// === MODE CHECKS ===
|
||||
// MapStart must exist
|
||||
mapstart:Result<(),()>,
|
||||
mapstart:Result<Exists,Absent>,
|
||||
// No duplicate map starts (including bonuses)
|
||||
mode_start_counts:DuplicateCheck<ModeID,Vec<&'a str>>,
|
||||
// At least one finish zone for each start zone, and no finishes with no start
|
||||
@@ -329,11 +414,11 @@ pub struct MapCheck<'a>{
|
||||
// Check for dangling MapAnticheat zones (no associated MapStart)
|
||||
mode_anticheat_counts:SetDifferenceCheck<SetDifferenceCheckContextAllowNone<ModeID,Vec<&'a str>>>,
|
||||
// Spawn1 must exist
|
||||
spawn1:Result<(),()>,
|
||||
spawn1:Result<Exists,Absent>,
|
||||
// Check for dangling Teleport# (no associated Spawn#)
|
||||
teleport_counts:SetDifferenceCheck<SetDifferenceCheckContextAllowNone<SpawnID,Vec<&'a str>>>,
|
||||
teleport_counts:SetDifferenceCheck<SetDifferenceCheckContextAllowNone<StageID,Vec<&'a str>>>,
|
||||
// No duplicate Spawn#
|
||||
spawn_counts:DuplicateCheck<SpawnID,u64>,
|
||||
spawn_counts:DuplicateCheck<StageID,u64>,
|
||||
// Check for dangling WormholeIn# (no associated WormholeOut#)
|
||||
wormhole_in_counts:SetDifferenceCheck<SetDifferenceCheckContextAtLeastOne<WormholeID,u64>>,
|
||||
// No duplicate WormholeOut# (duplicate WormholeIn# ok)
|
||||
@@ -370,17 +455,17 @@ impl<'a> ModelInfo<'a>{
|
||||
let game_id=self.map_info.game_id;
|
||||
|
||||
// MapStart must exist
|
||||
let mapstart=if self.counts.mode_start_counts.get(&ModeID::MAIN).is_some(){
|
||||
Ok(())
|
||||
let mapstart=if self.counts.mode_start_counts.contains_key(&ModeID::MAIN){
|
||||
Ok(Exists)
|
||||
}else{
|
||||
Err(())
|
||||
Err(Absent)
|
||||
};
|
||||
|
||||
// Spawn1 must exist
|
||||
let spawn1=if self.counts.spawn_counts.get(&SpawnID::FIRST).is_some(){
|
||||
Ok(())
|
||||
let spawn1=if self.counts.spawn_counts.contains_key(&StageID::FIRST){
|
||||
Ok(Exists)
|
||||
}else{
|
||||
Err(())
|
||||
Err(Absent)
|
||||
};
|
||||
|
||||
// Check that at least one finish zone exists for each start zone.
|
||||
@@ -431,8 +516,8 @@ impl<'a> ModelInfo<'a>{
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MapCheck<'a>{
|
||||
fn pass(self)->Result<MapInfoOwned,Self>{
|
||||
impl MapCheck<'_>{
|
||||
fn result(self)->Result<MapInfoOwned,Result<MapCheckList,serde_json::Error>>{
|
||||
match self{
|
||||
MapCheck{
|
||||
model_class:StringCheck(Ok(())),
|
||||
@@ -440,11 +525,11 @@ impl<'a> MapCheck<'a>{
|
||||
display_name:Ok(Ok(StringCheck(Ok(display_name)))),
|
||||
creator:Ok(Ok(creator)),
|
||||
game_id:Ok(game_id),
|
||||
mapstart:Ok(()),
|
||||
mapstart:Ok(Exists),
|
||||
mode_start_counts:DuplicateCheck(Ok(())),
|
||||
mode_finish_counts:SetDifferenceCheck(Ok(())),
|
||||
mode_anticheat_counts:SetDifferenceCheck(Ok(())),
|
||||
spawn1:Ok(()),
|
||||
spawn1:Ok(Exists),
|
||||
teleport_counts:SetDifferenceCheck(Ok(())),
|
||||
spawn_counts:DuplicateCheck(Ok(())),
|
||||
wormhole_in_counts:SetDifferenceCheck(Ok(())),
|
||||
@@ -456,152 +541,269 @@ impl<'a> MapCheck<'a>{
|
||||
game_id,
|
||||
})
|
||||
},
|
||||
other=>Err(other),
|
||||
other=>Err(other.itemize()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_comma_separated<T>(
|
||||
f:&mut std::fmt::Formatter<'_>,
|
||||
mut it:impl Iterator<Item=T>,
|
||||
custom_write:impl Fn(&mut std::fmt::Formatter<'_>,T)->std::fmt::Result
|
||||
)->std::fmt::Result{
|
||||
if let Some(t)=it.next(){
|
||||
custom_write(f,t)?;
|
||||
for t in it{
|
||||
write!(f,", ")?;
|
||||
custom_write(f,t)?;
|
||||
}
|
||||
struct Separated<F>{
|
||||
f:F,
|
||||
separator:&'static str,
|
||||
}
|
||||
impl<F> Separated<F>{
|
||||
fn new(separator:&'static str,f:F)->Self{
|
||||
Self{separator,f}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
macro_rules! write_zone{
|
||||
($f:expr,$mode:expr,$zone:expr)=>{
|
||||
match $mode{
|
||||
ModeID(0)=>write!($f,concat!("Map",$zone)),
|
||||
ModeID(1)=>write!($f,concat!("Bonus",$zone)),
|
||||
ModeID(other)=>write!($f,concat!("Bonus{}",$zone),other),
|
||||
}
|
||||
};
|
||||
}
|
||||
impl<'a> std::fmt::Display for MapCheck<'a>{
|
||||
impl<F,I,D> std::fmt::Display for Separated<F>
|
||||
where
|
||||
D:std::fmt::Display,
|
||||
I:IntoIterator<Item=D>,
|
||||
F:Fn()->I,
|
||||
{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
if let StringCheck(Err(context))=&self.model_class{
|
||||
writeln!(f,"Invalid model class: {context}")?;
|
||||
}
|
||||
if let StringCheck(Err(context))=&self.model_name{
|
||||
writeln!(f,"Model name must have snake_case: {context}")?;
|
||||
}
|
||||
match &self.display_name{
|
||||
Ok(Ok(StringCheck(Ok(_))))=>(),
|
||||
Ok(Ok(StringCheck(Err(context))))=>writeln!(f,"DisplayName must have Title Case: {context}")?,
|
||||
Ok(Err(context))=>writeln!(f,"Invalid DisplayName: {context}")?,
|
||||
Err(StringValueError::ObjectNotFound)=>writeln!(f,"Missing DisplayName StringValue")?,
|
||||
Err(StringValueError::ValueNotSet)=>writeln!(f,"DisplayName Value not set")?,
|
||||
Err(StringValueError::NonStringValue)=>writeln!(f,"DisplayName Value is not a String")?,
|
||||
}
|
||||
match &self.creator{
|
||||
Ok(Ok(_))=>(),
|
||||
Ok(Err(context))=>writeln!(f,"Invalid Creator: {context}")?,
|
||||
Err(StringValueError::ObjectNotFound)=>writeln!(f,"Missing Creator StringValue")?,
|
||||
Err(StringValueError::ValueNotSet)=>writeln!(f,"Creator Value not set")?,
|
||||
Err(StringValueError::NonStringValue)=>writeln!(f,"Creator Value is not a String")?,
|
||||
}
|
||||
if let Err(_parse_game_id_error)=&self.game_id{
|
||||
writeln!(f,"Model name must be prefixed with bhop_ surf_ or flytrials_")?;
|
||||
}
|
||||
if let Err(())=&self.mapstart{
|
||||
writeln!(f,"Model has no MapStart")?;
|
||||
}
|
||||
if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.mode_start_counts{
|
||||
write!(f,"Duplicate start zones: ")?;
|
||||
write_comma_separated(f,context.iter(),|f,(mode_id,names)|{
|
||||
write_zone!(f,mode_id,"Start")?;
|
||||
write!(f," ({} duplicates)",names.len())?;
|
||||
Ok(())
|
||||
})?;
|
||||
writeln!(f,"")?;
|
||||
}
|
||||
if let SetDifferenceCheck(Err(context))=&self.mode_finish_counts{
|
||||
if !context.extra.is_empty(){
|
||||
let plural=if context.extra.len()==1{"zone"}else{"zones"};
|
||||
write!(f,"No matching start zone for finish {plural}: ")?;
|
||||
write_comma_separated(f,context.extra.iter(),|f,(mode_id,_names)|
|
||||
write_zone!(f,mode_id,"Finish")
|
||||
)?;
|
||||
writeln!(f,"")?;
|
||||
let mut it=(self.f)().into_iter();
|
||||
if let Some(first)=it.next(){
|
||||
write!(f,"{first}")?;
|
||||
for item in it{
|
||||
write!(f,"{}{item}",self.separator)?;
|
||||
}
|
||||
if !context.missing.is_empty(){
|
||||
let plural=if context.missing.len()==1{"zone"}else{"zones"};
|
||||
write!(f,"Missing finish {plural}: ")?;
|
||||
write_comma_separated(f,context.missing.iter(),|f,mode_id|
|
||||
write_zone!(f,mode_id,"Finish")
|
||||
)?;
|
||||
writeln!(f,"")?;
|
||||
}
|
||||
}
|
||||
if let SetDifferenceCheck(Err(context))=&self.mode_anticheat_counts{
|
||||
if !context.extra.is_empty(){
|
||||
let plural=if context.extra.len()==1{"zone"}else{"zones"};
|
||||
write!(f,"No matching start zone for anticheat {plural}: ")?;
|
||||
write_comma_separated(f,context.extra.iter(),|f,(mode_id,_names)|
|
||||
write_zone!(f,mode_id,"Anticheat")
|
||||
)?;
|
||||
writeln!(f,"")?;
|
||||
}
|
||||
}
|
||||
if let Err(())=&self.spawn1{
|
||||
writeln!(f,"Model has no Spawn1")?;
|
||||
}
|
||||
if let SetDifferenceCheck(Err(context))=&self.teleport_counts{
|
||||
for (_,names) in &context.extra{
|
||||
let plural=if names.len()==1{"object"}else{"objects"};
|
||||
write!(f,"No matching Spawn for {plural}: ")?;
|
||||
write_comma_separated(f,names.iter(),|f,&name|{
|
||||
write!(f,"{name}")
|
||||
})?;
|
||||
writeln!(f,"")?;
|
||||
}
|
||||
}
|
||||
if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.spawn_counts{
|
||||
write!(f,"Duplicate Spawn: ")?;
|
||||
write_comma_separated(f,context.iter(),|f,(SpawnID(spawn_id),count)|
|
||||
write!(f,"Spawn{spawn_id} ({count} duplicates)")
|
||||
)?;
|
||||
writeln!(f,"")?;
|
||||
}
|
||||
if let SetDifferenceCheck(Err(context))=&self.wormhole_in_counts{
|
||||
if !context.extra.is_empty(){
|
||||
write!(f,"WormholeIn with no matching WormholeOut: ")?;
|
||||
write_comma_separated(f,context.extra.iter(),|f,(WormholeID(wormhole_id),_count)|
|
||||
write!(f,"WormholeIn{wormhole_id}")
|
||||
)?;
|
||||
writeln!(f,"")?;
|
||||
}
|
||||
if !context.missing.is_empty(){
|
||||
// This counts WormholeIn objects, but
|
||||
// flipped logic is easier to understand
|
||||
write!(f,"WormholeOut with no matching WormholeIn: ")?;
|
||||
write_comma_separated(f,context.missing.iter(),|f,WormholeID(wormhole_id)|
|
||||
write!(f,"WormholeOut{wormhole_id}")
|
||||
)?;
|
||||
writeln!(f,"")?;
|
||||
}
|
||||
}
|
||||
if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.wormhole_out_counts{
|
||||
write!(f,"Duplicate WormholeOut: ")?;
|
||||
write_comma_separated(f,context.iter(),|f,(WormholeID(wormhole_id),count)|
|
||||
write!(f,"WormholeOut{wormhole_id} ({count} duplicates)")
|
||||
)?;
|
||||
writeln!(f,"")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Duplicates<D>{
|
||||
display:D,
|
||||
duplicates:usize,
|
||||
}
|
||||
impl<D> Duplicates<D>{
|
||||
fn new(display:D,duplicates:usize)->Self{
|
||||
Self{
|
||||
display,
|
||||
duplicates,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<D:std::fmt::Display> std::fmt::Display for Duplicates<D>{
|
||||
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
|
||||
write!(f,"{} ({} duplicates)",self.display,self.duplicates)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct CheckSummary{
|
||||
name:&'static str,
|
||||
summary:String,
|
||||
passed:bool,
|
||||
details:serde_json::Value,
|
||||
}
|
||||
impl CheckSummary{
|
||||
const fn passed(name:&'static str)->Self{
|
||||
Self{
|
||||
name,
|
||||
summary:String::new(),
|
||||
passed:true,
|
||||
details:serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
macro_rules! summary{
|
||||
($name:literal,$summary:expr,$details:expr)=>{
|
||||
CheckSummary{
|
||||
name:$name,
|
||||
summary:$summary,
|
||||
passed:false,
|
||||
details:serde_json::to_value($details)?,
|
||||
}
|
||||
};
|
||||
}
|
||||
macro_rules! summary_format{
|
||||
($name:literal,$fmt:literal,$details:expr)=>{
|
||||
CheckSummary{
|
||||
name:$name,
|
||||
summary:format!($fmt),
|
||||
passed:false,
|
||||
details:serde_json::to_value($details)?,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Generate an error message for each observed issue separated by newlines.
|
||||
// This defines MapCheck.to_string() which is used in MapCheck.result()
|
||||
impl MapCheck<'_>{
|
||||
fn itemize(&self)->Result<MapCheckList,serde_json::Error>{
|
||||
let model_class=match &self.model_class{
|
||||
StringCheck(Ok(()))=>CheckSummary::passed("ModelClass"),
|
||||
StringCheck(Err(context))=>summary_format!("ModelClass","Invalid model class: {context}",()),
|
||||
};
|
||||
let model_name=match &self.model_name{
|
||||
StringCheck(Ok(()))=>CheckSummary::passed("ModelName"),
|
||||
StringCheck(Err(context))=>summary_format!("ModelName","Model name must have snake_case: {context}",()),
|
||||
};
|
||||
let display_name=match &self.display_name{
|
||||
Ok(Ok(StringCheck(Ok(_))))=>CheckSummary::passed("DisplayName"),
|
||||
Ok(Ok(StringCheck(Err(context))))=>summary_format!("DisplayName","DisplayName must have Title Case: {context}",()),
|
||||
Ok(Err(context))=>summary_format!("DisplayName","Invalid DisplayName: {context}",()),
|
||||
Err(StringValueError::ObjectNotFound)=>summary!("DisplayName","Missing DisplayName StringValue".to_owned(),()),
|
||||
Err(StringValueError::ValueNotSet)=>summary!("DisplayName","DisplayName Value not set".to_owned(),()),
|
||||
Err(StringValueError::NonStringValue)=>summary!("DisplayName","DisplayName Value is not a String".to_owned(),()),
|
||||
};
|
||||
let creator=match &self.creator{
|
||||
Ok(Ok(_))=>CheckSummary::passed("Creator"),
|
||||
Ok(Err(context))=>summary_format!("Creator","Invalid Creator: {context}",()),
|
||||
Err(StringValueError::ObjectNotFound)=>summary!("Creator","Missing Creator StringValue".to_owned(),()),
|
||||
Err(StringValueError::ValueNotSet)=>summary!("Creator","Creator Value not set".to_owned(),()),
|
||||
Err(StringValueError::NonStringValue)=>summary!("Creator","Creator Value is not a String".to_owned(),()),
|
||||
};
|
||||
let game_id=match &self.game_id{
|
||||
Ok(_)=>CheckSummary::passed("GameID"),
|
||||
Err(ParseGameIDError)=>summary!("GameID","Model name must be prefixed with bhop_ surf_ or flytrials_".to_owned(),()),
|
||||
};
|
||||
let mapstart=match &self.mapstart{
|
||||
Ok(Exists)=>CheckSummary::passed("MapStart"),
|
||||
Err(Absent)=>summary_format!("MapStart","Model has no MapStart",()),
|
||||
};
|
||||
let duplicate_start=match &self.mode_start_counts{
|
||||
DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateStart"),
|
||||
DuplicateCheck(Err(DuplicateCheckContext(context)))=>{
|
||||
let context=Separated::new(", ",||context.iter().map(|(&mode_id,names)|
|
||||
Duplicates::new(ModeElement{zone:Zone::Start,mode_id},names.len())
|
||||
));
|
||||
summary_format!("DuplicateStart","Duplicate start zones: {context}",())
|
||||
}
|
||||
};
|
||||
let (extra_finish,missing_finish)=match &self.mode_finish_counts{
|
||||
SetDifferenceCheck(Ok(()))=>(CheckSummary::passed("ExtraFinish"),CheckSummary::passed("MissingFinish")),
|
||||
SetDifferenceCheck(Err(context))=>(
|
||||
if context.extra.is_empty(){
|
||||
CheckSummary::passed("ExtraFinish")
|
||||
}else{
|
||||
let plural=if context.extra.len()==1{"zone"}else{"zones"};
|
||||
let context=Separated::new(", ",||context.extra.iter().map(|(&mode_id,_names)|
|
||||
ModeElement{zone:Zone::Finish,mode_id}
|
||||
));
|
||||
summary_format!("ExtraFinish","No matching start zone for finish {plural}: {context}",())
|
||||
},
|
||||
if context.missing.is_empty(){
|
||||
CheckSummary::passed("MissingFinish")
|
||||
}else{
|
||||
let plural=if context.missing.len()==1{"zone"}else{"zones"};
|
||||
let context=Separated::new(", ",||context.missing.iter().map(|&mode_id|
|
||||
ModeElement{zone:Zone::Finish,mode_id}
|
||||
));
|
||||
summary_format!("MissingFinish","Missing finish {plural}: {context}",())
|
||||
}
|
||||
),
|
||||
};
|
||||
let dangling_anticheat=match &self.mode_anticheat_counts{
|
||||
SetDifferenceCheck(Ok(()))=>CheckSummary::passed("DanglingAnticheat"),
|
||||
SetDifferenceCheck(Err(context))=>{
|
||||
if context.extra.is_empty(){
|
||||
CheckSummary::passed("DanglingAnticheat")
|
||||
}else{
|
||||
let plural=if context.extra.len()==1{"zone"}else{"zones"};
|
||||
let context=Separated::new(", ",||context.extra.iter().map(|(&mode_id,_names)|
|
||||
ModeElement{zone:Zone::Anticheat,mode_id}
|
||||
));
|
||||
summary_format!("DanglingAnticheat","No matching start zone for anticheat {plural}: {context}",())
|
||||
}
|
||||
}
|
||||
};
|
||||
let spawn1=match &self.spawn1{
|
||||
Ok(Exists)=>CheckSummary::passed("Spawn1"),
|
||||
Err(Absent)=>summary_format!("Spawn1","Model has no Spawn1",()),
|
||||
};
|
||||
let dangling_teleport=match &self.teleport_counts{
|
||||
SetDifferenceCheck(Ok(()))=>CheckSummary::passed("DanglingTeleport"),
|
||||
SetDifferenceCheck(Err(context))=>{
|
||||
let unique_names:HashSet<_>=context.extra.values().flat_map(|names|names.iter().copied()).collect();
|
||||
let plural=if unique_names.len()==1{"object"}else{"objects"};
|
||||
let context=Separated::new(", ",||&unique_names);
|
||||
summary_format!("DanglingTeleport","No matching Spawn for {plural}: {context}",())
|
||||
}
|
||||
};
|
||||
let duplicate_spawns=match &self.spawn_counts{
|
||||
DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateSpawn"),
|
||||
DuplicateCheck(Err(DuplicateCheckContext(context)))=>{
|
||||
let context=Separated::new(", ",||context.iter().map(|(&stage_id,&names)|
|
||||
Duplicates::new(StageElement{behaviour:StageElementBehaviour::Spawn,stage_id},names as usize)
|
||||
));
|
||||
summary_format!("DuplicateSpawn","Duplicate Spawn: {context}",())
|
||||
}
|
||||
};
|
||||
let (extra_wormhole_in,missing_wormhole_in)=match &self.wormhole_in_counts{
|
||||
SetDifferenceCheck(Ok(()))=>(CheckSummary::passed("ExtraWormholeIn"),CheckSummary::passed("MissingWormholeIn")),
|
||||
SetDifferenceCheck(Err(context))=>(
|
||||
if context.extra.is_empty(){
|
||||
CheckSummary::passed("ExtraWormholeIn")
|
||||
}else{
|
||||
let context=Separated::new(", ",||context.extra.iter().map(|(&wormhole_id,_names)|
|
||||
WormholeElement{behaviour:WormholeBehaviour::In,wormhole_id}
|
||||
));
|
||||
summary_format!("ExtraWormholeIn","WormholeIn with no matching WormholeOut: {context}",())
|
||||
},
|
||||
if context.missing.is_empty(){
|
||||
CheckSummary::passed("MissingWormholeIn")
|
||||
}else{
|
||||
// This counts WormholeIn objects, but
|
||||
// flipped logic is easier to understand
|
||||
let context=Separated::new(", ",||context.missing.iter().map(|&wormhole_id|
|
||||
WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id}
|
||||
));
|
||||
summary_format!("MissingWormholeIn","WormholeOut with no matching WormholeIn: {context}",())
|
||||
}
|
||||
)
|
||||
};
|
||||
let duplicate_wormhole_out=match &self.wormhole_out_counts{
|
||||
DuplicateCheck(Ok(()))=>CheckSummary::passed("DuplicateWormholeOut"),
|
||||
DuplicateCheck(Err(DuplicateCheckContext(context)))=>{
|
||||
let context=Separated::new(", ",||context.iter().map(|(&wormhole_id,&names)|
|
||||
Duplicates::new(WormholeElement{behaviour:WormholeBehaviour::Out,wormhole_id},names as usize)
|
||||
));
|
||||
summary_format!("DuplicateWormholeOut","Duplicate WormholeOut: {context}",())
|
||||
}
|
||||
};
|
||||
Ok(MapCheckList{checks:Box::new([
|
||||
model_class,
|
||||
model_name,
|
||||
display_name,
|
||||
creator,
|
||||
game_id,
|
||||
mapstart,
|
||||
duplicate_start,
|
||||
extra_finish,
|
||||
missing_finish,
|
||||
dangling_anticheat,
|
||||
spawn1,
|
||||
dangling_teleport,
|
||||
duplicate_spawns,
|
||||
extra_wormhole_in,
|
||||
missing_wormhole_in,
|
||||
duplicate_wormhole_out,
|
||||
])})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct MapCheckList{
|
||||
checks:Box<[CheckSummary;16]>,
|
||||
}
|
||||
impl MapCheckList{
|
||||
fn summary(&self)->String{
|
||||
Separated::new("; ",||self.checks.iter().filter_map(|check|
|
||||
(!check.passed).then_some(check.summary.as_str())
|
||||
)).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Summary{
|
||||
pub summary:String,
|
||||
pub json:serde_json::Value,
|
||||
}
|
||||
|
||||
pub struct CheckReportAndVersion{
|
||||
pub status:Result<MapInfoOwned,String>,
|
||||
pub status:Result<MapInfoOwned,Summary>,
|
||||
pub version:u64,
|
||||
}
|
||||
|
||||
@@ -638,7 +840,14 @@ impl crate::message_handler::MessageHandler{
|
||||
let map_check=model_info.check();
|
||||
|
||||
// check the report, generate an error message if it fails the check
|
||||
let status=map_check.pass().map_err(|e|e.to_string());
|
||||
let status=match map_check.result(){
|
||||
Ok(map_info)=>Ok(map_info),
|
||||
Err(Ok(summary))=>Err(Summary{
|
||||
summary:summary.summary(),
|
||||
json:serde_json::to_value(&summary).map_err(Error::ToJsonValue)?,
|
||||
}),
|
||||
Err(Err(e))=>return Err(Error::ToJsonValue(e)),
|
||||
};
|
||||
|
||||
Ok(CheckReportAndVersion{status,version})
|
||||
}
|
||||
|
||||
@@ -34,17 +34,21 @@ impl crate::message_handler::MessageHandler{
|
||||
Ok(CheckReportAndVersion{status:Err(report),..})=>self.api.action_mapfix_request_changes(
|
||||
submissions_api::types::ActionMapfixRequestChangesRequest{
|
||||
MapfixID:mapfix_id,
|
||||
ErrorMessage:report,
|
||||
ErrorMessage:report.summary,
|
||||
}
|
||||
).await.map_err(Error::ApiActionMapfixCheck)?,
|
||||
// TODO: report the error
|
||||
// update the mapfix model status to request changes
|
||||
Err(e)=>self.api.action_mapfix_request_changes(
|
||||
submissions_api::types::ActionMapfixRequestChangesRequest{
|
||||
MapfixID:mapfix_id,
|
||||
ErrorMessage:e.to_string(),
|
||||
}
|
||||
).await.map_err(Error::ApiActionMapfixCheck)?,
|
||||
Err(e)=>{
|
||||
// log error
|
||||
println!("[check_mapfix] Error: {e}");
|
||||
|
||||
self.api.action_mapfix_request_changes(
|
||||
submissions_api::types::ActionMapfixRequestChangesRequest{
|
||||
MapfixID:mapfix_id,
|
||||
ErrorMessage:e.to_string(),
|
||||
}
|
||||
).await.map_err(Error::ApiActionMapfixCheck)?;
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -35,17 +35,21 @@ impl crate::message_handler::MessageHandler{
|
||||
Ok(CheckReportAndVersion{status:Err(report),..})=>self.api.action_submission_request_changes(
|
||||
submissions_api::types::ActionSubmissionRequestChangesRequest{
|
||||
SubmissionID:submission_id,
|
||||
ErrorMessage:report,
|
||||
ErrorMessage:report.summary,
|
||||
}
|
||||
).await.map_err(Error::ApiActionSubmissionCheck)?,
|
||||
// TODO: report the error
|
||||
// update the submission model status to request changes
|
||||
Err(e)=>self.api.action_submission_request_changes(
|
||||
submissions_api::types::ActionSubmissionRequestChangesRequest{
|
||||
SubmissionID:submission_id,
|
||||
ErrorMessage:e.to_string(),
|
||||
}
|
||||
).await.map_err(Error::ApiActionSubmissionCheck)?,
|
||||
Err(e)=>{
|
||||
// log error
|
||||
println!("[check_submission] Error: {e}");
|
||||
|
||||
self.api.action_submission_request_changes(
|
||||
submissions_api::types::ActionSubmissionRequestChangesRequest{
|
||||
SubmissionID:submission_id,
|
||||
ErrorMessage:e.to_string(),
|
||||
}
|
||||
).await.map_err(Error::ApiActionSubmissionCheck)?;
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -43,6 +43,9 @@ impl crate::message_handler::MessageHandler{
|
||||
let create_result=self.create_mapfix_inner(create_info).await;
|
||||
|
||||
if let Err(e)=create_result{
|
||||
// log error
|
||||
println!("[create_mapfix] Error: {e}");
|
||||
|
||||
self.api.action_operation_failed(submissions_api::types::ActionOperationFailedRequest{
|
||||
OperationID:operation_id,
|
||||
StatusMessage:e.to_string(),
|
||||
|
||||
@@ -57,6 +57,9 @@ impl crate::message_handler::MessageHandler{
|
||||
let create_result=self.create_submission_inner(create_info).await;
|
||||
|
||||
if let Err(e)=create_result{
|
||||
// log error
|
||||
println!("[create_submission] Error: {e}");
|
||||
|
||||
self.api.action_operation_failed(submissions_api::types::ActionOperationFailedRequest{
|
||||
OperationID:operation_id,
|
||||
StatusMessage:e.to_string(),
|
||||
|
||||
@@ -43,7 +43,7 @@ async fn main()->Result<(),StartupError>{
|
||||
"None"=>None,
|
||||
_=>Some(s.parse().expect("ROBLOX_GROUP_ID int parse")),
|
||||
},
|
||||
Err(e)=>Err(e).expect("ROBLOX_GROUP_ID env required"),
|
||||
Err(e)=>panic!("{e}: ROBLOX_GROUP_ID env required"),
|
||||
};
|
||||
|
||||
// create / upload models through STRAFESNET_CI2 account
|
||||
|
||||
@@ -24,28 +24,12 @@ pub fn read_dom<R:std::io::Read>(input:R)->Result<rbx_dom_weak::WeakDom,ReadDomE
|
||||
}
|
||||
}
|
||||
|
||||
pub fn class_is_a(class:&str,superclass:&str)->bool{
|
||||
if class==superclass{
|
||||
return true
|
||||
}
|
||||
let class_descriptor=rbx_reflection_database::get().classes.get(class);
|
||||
if let Some(descriptor)=&class_descriptor{
|
||||
if let Some(class_super)=&descriptor.superclass{
|
||||
return class_is_a(&class_super,superclass)
|
||||
}
|
||||
}
|
||||
false
|
||||
pub fn static_ustr(s:&'static str)->rbx_dom_weak::Ustr{
|
||||
rbx_dom_weak::ustr(s)
|
||||
}
|
||||
|
||||
pub fn find_first_child_class<'a>(dom:&'a rbx_dom_weak::WeakDom,instance:&rbx_dom_weak::Instance,name:&str,class:&str)->Option<&'a rbx_dom_weak::Instance>{
|
||||
for &referent in instance.children(){
|
||||
if let Some(c)=dom.get_by_ref(referent){
|
||||
if c.name==name&&class_is_a(c.class.as_str(),class) {
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
fn find_first_child_name_and_class<'a>(dom:&'a rbx_dom_weak::WeakDom,instance:&rbx_dom_weak::Instance,name:&str,class:&str)->Option<&'a rbx_dom_weak::Instance>{
|
||||
instance.children().iter().filter_map(|&r|dom.get_by_ref(r)).find(|inst|inst.name==name&&inst.class==class)
|
||||
}
|
||||
|
||||
pub enum GameID{
|
||||
@@ -67,7 +51,7 @@ impl std::str::FromStr for GameID{
|
||||
if s.starts_with("flytrials_"){
|
||||
return Ok(GameID::FlyTrials);
|
||||
}
|
||||
return Err(ParseGameIDError);
|
||||
Err(ParseGameIDError)
|
||||
}
|
||||
}
|
||||
pub struct GameIDError;
|
||||
@@ -98,7 +82,7 @@ pub enum StringValueError{
|
||||
|
||||
fn string_value(instance:Option<&rbx_dom_weak::Instance>)->Result<&str,StringValueError>{
|
||||
let instance=instance.ok_or(StringValueError::ObjectNotFound)?;
|
||||
let value=instance.properties.get("Value").ok_or(StringValueError::ValueNotSet)?;
|
||||
let value=instance.properties.get(&static_ustr("Value")).ok_or(StringValueError::ValueNotSet)?;
|
||||
match value{
|
||||
rbx_dom_weak::types::Variant::String(value)=>Ok(value),
|
||||
_=>Err(StringValueError::NonStringValue),
|
||||
@@ -122,8 +106,8 @@ pub fn get_root_instance(dom:&rbx_dom_weak::WeakDom)->Result<&rbx_dom_weak::Inst
|
||||
|
||||
pub fn get_mapinfo<'a>(dom:&'a rbx_dom_weak::WeakDom,model_instance:&rbx_dom_weak::Instance)->MapInfo<'a>{
|
||||
MapInfo{
|
||||
display_name:string_value(find_first_child_class(dom,model_instance,"DisplayName","StringValue")),
|
||||
creator:string_value(find_first_child_class(dom,model_instance,"Creator","StringValue")),
|
||||
display_name:string_value(find_first_child_name_and_class(dom,model_instance,"DisplayName","StringValue")),
|
||||
creator:string_value(find_first_child_name_and_class(dom,model_instance,"Creator","StringValue")),
|
||||
game_id:model_instance.name.parse(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ impl crate::message_handler::MessageHandler{
|
||||
).await.map_err(Error::ApiActionMapfixValidate)?;
|
||||
},
|
||||
Err(e)=>{
|
||||
// log error
|
||||
println!("[validate_mapfix] Error: {e}");
|
||||
|
||||
// update the mapfix model status to accepted
|
||||
self.api.action_mapfix_accepted(submissions_api::types::ActionMapfixAcceptedRequest{
|
||||
MapfixID:mapfix_id,
|
||||
|
||||
@@ -26,6 +26,9 @@ impl crate::message_handler::MessageHandler{
|
||||
).await.map_err(Error::ApiActionSubmissionValidate)?;
|
||||
},
|
||||
Err(e)=>{
|
||||
// log error
|
||||
println!("[validate_submission] Error: {e}");
|
||||
|
||||
// update the submission model status to accepted
|
||||
self.api.action_submission_accepted(submissions_api::types::ActionSubmissionAcceptedRequest{
|
||||
SubmissionID:submission_id,
|
||||
|
||||
@@ -2,7 +2,7 @@ use futures::TryStreamExt;
|
||||
use submissions_api::types::ResourceType;
|
||||
|
||||
use crate::download::download_asset_version;
|
||||
use crate::rbx_util::{class_is_a,read_dom,ReadDomError};
|
||||
use crate::rbx_util::{read_dom,static_ustr,ReadDomError};
|
||||
use crate::types::ResourceID;
|
||||
|
||||
const SCRIPT_CONCURRENCY:usize=16;
|
||||
@@ -21,7 +21,7 @@ struct NamePolicy{
|
||||
}
|
||||
|
||||
fn source_has_illegal_keywords(source:&str)->bool{
|
||||
source.find("getfenv").is_some()||source.find("require").is_some()
|
||||
source.contains("getfenv")||source.contains("require")
|
||||
}
|
||||
|
||||
fn hash_source(source:&str)->String{
|
||||
@@ -39,11 +39,11 @@ pub enum Error{
|
||||
ScriptNotYetReviewed(Option<submissions_api::types::ScriptID>),
|
||||
Download(crate::download::Error),
|
||||
ModelFileDecode(ReadDomError),
|
||||
ApiGetScriptPolicyFromHash(submissions_api::types::SingleItemError),
|
||||
ApiGetScriptPolicyFromHash(submissions_api::types::ScriptPolicySingleItemError),
|
||||
ApiGetScript(submissions_api::Error),
|
||||
ApiCreateScript(submissions_api::Error),
|
||||
ApiCreateScriptPolicy(submissions_api::Error),
|
||||
ApiGetScriptFromHash(submissions_api::types::SingleItemError),
|
||||
ApiGetScriptFromHash(submissions_api::types::ScriptSingleItemError),
|
||||
ApiUpdateMapfixModel(submissions_api::Error),
|
||||
ApiUpdateSubmissionModel(submissions_api::Error),
|
||||
ModelFileRootMustHaveOneChild,
|
||||
@@ -101,12 +101,15 @@ impl crate::message_handler::MessageHandler{
|
||||
|
||||
/* VALIDATE MAP */
|
||||
|
||||
// stupid ustr thing
|
||||
let source_property=static_ustr("Source");
|
||||
|
||||
// collect unique scripts
|
||||
let script_refs=get_script_refs(&dom);
|
||||
let mut script_map=std::collections::HashMap::<String,NamePolicy>::new();
|
||||
for &script_ref in &script_refs{
|
||||
if let Some(script)=dom.get_by_ref(script_ref){
|
||||
if let Some(rbx_dom_weak::types::Variant::String(source))=script.properties.get("Source"){
|
||||
if let Some(rbx_dom_weak::types::Variant::String(source))=script.properties.get(&source_property){
|
||||
// check the source for illegal keywords
|
||||
if source_has_illegal_keywords(source){
|
||||
// immediately abort
|
||||
@@ -179,7 +182,7 @@ impl crate::message_handler::MessageHandler{
|
||||
let mut modified=false;
|
||||
for &script_ref in &script_refs{
|
||||
if let Some(script)=dom.get_by_ref_mut(script_ref){
|
||||
if let Some(rbx_dom_weak::types::Variant::String(source))=script.properties.get_mut("Source"){
|
||||
if let Some(rbx_dom_weak::types::Variant::String(source))=script.properties.get_mut(&source_property){
|
||||
match script_map.get(source.as_str()).map(|p|&p.policy){
|
||||
Some(Policy::Blocked)=>{
|
||||
let hash=hash_source(source.as_str());
|
||||
@@ -277,17 +280,6 @@ impl crate::message_handler::MessageHandler{
|
||||
}
|
||||
}
|
||||
|
||||
fn recursive_collect_superclass(objects:&mut std::vec::Vec<rbx_dom_weak::types::Ref>,dom:&rbx_dom_weak::WeakDom,instance:&rbx_dom_weak::Instance,superclass:&str){
|
||||
for &referent in instance.children(){
|
||||
if let Some(c)=dom.get_by_ref(referent){
|
||||
if class_is_a(c.class.as_str(),superclass){
|
||||
objects.push(c.referent());//copy ref
|
||||
}
|
||||
recursive_collect_superclass(objects,dom,c,superclass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_partial_path(dom:&rbx_dom_weak::WeakDom,instance:&rbx_dom_weak::Instance)->String{
|
||||
let mut names:Vec<_>=core::iter::successors(
|
||||
Some(instance),
|
||||
@@ -302,7 +294,11 @@ fn get_partial_path(dom:&rbx_dom_weak::WeakDom,instance:&rbx_dom_weak::Instance)
|
||||
}
|
||||
|
||||
fn get_script_refs(dom:&rbx_dom_weak::WeakDom)->Vec<rbx_dom_weak::types::Ref>{
|
||||
let mut scripts=std::vec::Vec::new();
|
||||
recursive_collect_superclass(&mut scripts,dom,dom.root(),"LuaSourceContainer");
|
||||
scripts
|
||||
let db=rbx_reflection_database::get();
|
||||
let superclass=&db.classes["LuaSourceContainer"];
|
||||
dom.descendants().filter_map(|inst|{
|
||||
let class=db.classes.get(inst.class.as_str())?;
|
||||
db.has_superclass(class,superclass)
|
||||
.then_some(inst.referent())
|
||||
}).collect()
|
||||
}
|
||||
|
||||
BIN
web/public/errors/404.png
Normal file
BIN
web/public/errors/404.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
web/public/errors/500.png
Normal file
BIN
web/public/errors/500.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
151
web/src/app/_components/carousel.tsx
Normal file
151
web/src/app/_components/carousel.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import {Box, IconButton, Typography} from "@mui/material";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import Link from "next/link";
|
||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||
import {SubmissionInfo} from "@/app/ts/Submission";
|
||||
import {MapfixInfo} from "@/app/ts/Mapfix";
|
||||
|
||||
// Type for the items in the carousel
|
||||
type CarouselItem = SubmissionInfo | MapfixInfo;
|
||||
|
||||
// Props for the Carousel component
|
||||
interface CarouselProps<T extends CarouselItem> {
|
||||
title: string;
|
||||
items: T[] | undefined;
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
viewAllLink: string;
|
||||
}
|
||||
|
||||
export function Carousel<T extends CarouselItem>({ title, items, renderItem, viewAllLink }: CarouselProps<T>) {
|
||||
const carouselRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollPosition, setScrollPosition] = useState<number>(0);
|
||||
const [maxScroll, setMaxScroll] = useState<number>(0);
|
||||
|
||||
const SCROLL_AMOUNT = 300;
|
||||
|
||||
useEffect(() => {
|
||||
if (carouselRef.current) {
|
||||
const scrollWidth = carouselRef.current.scrollWidth;
|
||||
const clientWidth = carouselRef.current.clientWidth;
|
||||
setMaxScroll(scrollWidth - clientWidth);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const scroll = (direction: 'left' | 'right'): void => {
|
||||
if (carouselRef.current) {
|
||||
const scrollAmount = direction === 'left' ? -SCROLL_AMOUNT : SCROLL_AMOUNT;
|
||||
|
||||
carouselRef.current.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (carouselRef.current) {
|
||||
setScrollPosition(carouselRef.current.scrollLeft);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (carouselRef.current) {
|
||||
setScrollPosition(carouselRef.current.scrollLeft);
|
||||
}
|
||||
};
|
||||
|
||||
const ref = carouselRef.current;
|
||||
if (ref) {
|
||||
ref.addEventListener('scroll', handleScroll);
|
||||
return () => ref.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box mb={6}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4" component="h2" fontWeight="bold">
|
||||
{title}
|
||||
</Typography>
|
||||
<Link href={viewAllLink} style={{textDecoration: 'none'}}>
|
||||
<Typography component="span" color="primary">
|
||||
View All →
|
||||
</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box position="relative">
|
||||
<IconButton
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: -20,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
visibility: scrollPosition <= 5 ? 'hidden' : 'visible',
|
||||
}}
|
||||
onClick={() => scroll('left')}
|
||||
>
|
||||
<ArrowBackIosNewIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
ref={carouselRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
gap: '16px', // Fixed 16px gap - using string with px unit to ensure it's absolute
|
||||
padding: '8px 4px',
|
||||
}}
|
||||
>
|
||||
{items?.map((item, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: {
|
||||
xs: '260px', // Fixed width at different breakpoints
|
||||
sm: '280px',
|
||||
md: '300px'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderItem(item)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: -20,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
visibility: scrollPosition >= maxScroll - 5 ? 'hidden' : 'visible',
|
||||
}}
|
||||
onClick={() => scroll('right')}
|
||||
>
|
||||
<ArrowForwardIosIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,62 +3,132 @@
|
||||
import Link from "next/link"
|
||||
import Image from "next/image";
|
||||
|
||||
import "./styles/header.scss"
|
||||
import { UserInfo } from "@/app/ts/User";
|
||||
import { useState, useEffect } from "react";
|
||||
import {UserInfo} from "@/app/ts/User";
|
||||
import {useState, useEffect} from "react";
|
||||
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
interface HeaderButton {
|
||||
name: string,
|
||||
href: string
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
function HeaderButton(header: HeaderButton) {
|
||||
return (
|
||||
<Link href={header.href}>
|
||||
<button>{header.name}</button>
|
||||
</Link>
|
||||
)
|
||||
return (
|
||||
<Button color="inherit" component={Link} href={header.href}>
|
||||
{header.name}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const handleLoginClick = () => {
|
||||
window.location.href = "/auth/oauth2/login?redirect=" + window.location.href;
|
||||
};
|
||||
const handleLoginClick = () => {
|
||||
window.location.href =
|
||||
"/auth/oauth2/login?redirect=" + window.location.href;
|
||||
};
|
||||
|
||||
const [valid, setValid] = useState<boolean>(false)
|
||||
const [user, setUser] = useState<UserInfo | null>(null)
|
||||
const [valid, setValid] = useState<boolean>(false);
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getLoginInfo() {
|
||||
const [validateData, userData] = await Promise.all([
|
||||
fetch("/api/session/validate").then(validateResponse => validateResponse.json()),
|
||||
fetch("/api/session/user").then(userResponse => userResponse.json())
|
||||
]);
|
||||
setValid(validateData)
|
||||
setUser(userData)
|
||||
}
|
||||
getLoginInfo()
|
||||
}, [])
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="header-bar">
|
||||
<nav className="left">
|
||||
<HeaderButton name="Submissions" href="/submissions"/>
|
||||
<HeaderButton name="Mapfixes" href="/mapfixes"/>
|
||||
<HeaderButton name="Maps" href="/maps"/>
|
||||
</nav>
|
||||
<nav className="right">
|
||||
<HeaderButton name="Submit" href="/submit"/>
|
||||
{valid && user ? (
|
||||
<div className="author">
|
||||
<Link href="/auth">
|
||||
<Image className="avatar" width={28} height={28} priority={true} src={user.AvatarURL} alt={user.Username}/>
|
||||
<button>{user.Username}</button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleLoginClick}>Login</button>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function getLoginInfo() {
|
||||
try {
|
||||
const response = await fetch("/api/session/user");
|
||||
|
||||
if (!response.ok) {
|
||||
setValid(false);
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
const isLoggedIn = userData && 'UserID' in userData;
|
||||
|
||||
setValid(isLoggedIn);
|
||||
setUser(isLoggedIn ? userData : null);
|
||||
} catch (error) {
|
||||
console.error("Error fetching user data:", error);
|
||||
setValid(false);
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
|
||||
getLoginInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Box display="flex" flexGrow={1} gap={2}>
|
||||
<HeaderButton name="Submissions" href="/submissions"/>
|
||||
<HeaderButton name="Mapfixes" href="/mapfixes"/>
|
||||
<HeaderButton name="Maps" href="/maps"/>
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
{valid && user && (
|
||||
<Button variant="outlined" color="success" component={Link} href="/submit">
|
||||
Submit Map
|
||||
</Button>
|
||||
)}
|
||||
{valid && user ? (
|
||||
<Box display="flex" alignItems="center">
|
||||
<Button
|
||||
onClick={handleMenuOpen}
|
||||
color="inherit"
|
||||
size="small"
|
||||
style={{textTransform: "none"}}
|
||||
>
|
||||
<Image
|
||||
className="avatar"
|
||||
width={28}
|
||||
height={28}
|
||||
priority={true}
|
||||
src={user.AvatarURL}
|
||||
alt={user.Username}
|
||||
style={{marginRight: 8}}
|
||||
/>
|
||||
<Typography variant="body1">{user.Username}</Typography>
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
>
|
||||
<MenuItem component={Link} href="/auth">
|
||||
Manage
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
) : (
|
||||
<Button color="inherit" onClick={handleLoginClick}>
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,71 +1,268 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Rating } from "@mui/material";
|
||||
import React, {JSX} from "react";
|
||||
import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Chip, Divider, Grid, Typography} from "@mui/material";
|
||||
import {Cancel, CheckCircle, Explore, Pending, Person2} from "@mui/icons-material";
|
||||
|
||||
interface SubmissionCardProps {
|
||||
interface MapCardProps {
|
||||
displayName: string;
|
||||
assetId: number;
|
||||
authorId: number;
|
||||
author: string;
|
||||
rating: number;
|
||||
id: number;
|
||||
statusID: number;
|
||||
gameID: number;
|
||||
created: number;
|
||||
type: 'mapfix' | 'submission';
|
||||
}
|
||||
|
||||
export function SubmissionCard(props: SubmissionCardProps) {
|
||||
return (
|
||||
<Link href={`/submissions/${props.id}`}>
|
||||
<div className="submissionCard">
|
||||
<div className="content">
|
||||
<div className="map-image">
|
||||
{/* TODO: Grab image of model */}
|
||||
<Image width={230} height={230} layout="fixed" priority={true} src={`/thumbnails/asset/${props.assetId}`} alt={props.displayName} />
|
||||
</div>
|
||||
<div className="details">
|
||||
<div className="header">
|
||||
<span className="displayName">{props.displayName}</span>
|
||||
<div className="rating">
|
||||
<Rating value={props.rating} readOnly size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<div className="author">
|
||||
<Image className="avatar" width={28} height={28} priority={true} src={`/thumbnails/user/${props.authorId}`} alt={props.author}/>
|
||||
<span>{props.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
const CARD_WIDTH = 270;
|
||||
|
||||
export function MapfixCard(props: SubmissionCardProps) {
|
||||
export function MapCard(props: MapCardProps) {
|
||||
const StatusChip = ({status}: { status: number }) => {
|
||||
let color: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default';
|
||||
let icon: JSX.Element = <Pending fontSize="small"/>;
|
||||
let label: string = 'Unknown';
|
||||
|
||||
switch (status) {
|
||||
case 0:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Under Construction';
|
||||
break;
|
||||
case 1:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Changes Requested';
|
||||
break;
|
||||
case 2:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Submitting';
|
||||
break;
|
||||
case 3:
|
||||
color = 'warning';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Under Review';
|
||||
break;
|
||||
case 4:
|
||||
color = 'warning';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Accepted Unvalidated';
|
||||
break;
|
||||
case 5:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Validating';
|
||||
break;
|
||||
case 6:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Validated';
|
||||
break;
|
||||
case 7:
|
||||
color = 'info';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Uploading';
|
||||
break;
|
||||
case 8:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Uploaded';
|
||||
break;
|
||||
case 9:
|
||||
color = 'error';
|
||||
icon = <Cancel fontSize="small"/>;
|
||||
label = 'Rejected';
|
||||
break;
|
||||
case 10:
|
||||
color = 'success';
|
||||
icon = <CheckCircle fontSize="small"/>;
|
||||
label = 'Released';
|
||||
break;
|
||||
default:
|
||||
color = 'default';
|
||||
icon = <Pending fontSize="small"/>;
|
||||
label = 'Unknown';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={icon}
|
||||
label={label}
|
||||
color={color}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Link href={`/mapfixes/${props.id}`}>
|
||||
<div className="MapfixCard">
|
||||
<div className="content">
|
||||
<div className="map-image">
|
||||
{/* TODO: Grab image of model */}
|
||||
<Image width={230} height={230} layout="fixed" priority={true} src={`/thumbnails/asset/${props.assetId}`} alt={props.displayName} />
|
||||
</div>
|
||||
<div className="details">
|
||||
<div className="header">
|
||||
<span className="displayName">{props.displayName}</span>
|
||||
<div className="rating">
|
||||
<Rating value={props.rating} readOnly size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<div className="author">
|
||||
<Image className="avatar" width={28} height={28} priority={true} src={`/thumbnails/user/${props.authorId}`} alt={props.author}/>
|
||||
<span>{props.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
<Grid item xs={12} sm={6} md={3} key={props.assetId}>
|
||||
<Box sx={{
|
||||
width: CARD_WIDTH,
|
||||
mx: 'auto', // Center the card in its grid cell
|
||||
}}>
|
||||
<Card sx={{
|
||||
width: CARD_WIDTH,
|
||||
height: 340, // Fixed height for all cards
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<CardActionArea
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch'
|
||||
}}
|
||||
href={`/${props.type === 'submission' ? 'submissions' : 'mapfixes'}/${props.id}`}>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={`/thumbnails/asset/${props.assetId}`}
|
||||
alt={props.displayName}
|
||||
sx={{
|
||||
height: 160, // Fixed height for all images
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<StatusChip status={props.statusID}/>
|
||||
</Box>
|
||||
</Box>
|
||||
<CardContent sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
width: '100%',
|
||||
}}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
sx={{
|
||||
mb: 1,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
lineHeight: '1.3',
|
||||
// Allow text to wrap
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{props.displayName}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
mb: 1.5,
|
||||
}}>
|
||||
<Explore sx={{
|
||||
mr: 0.75,
|
||||
mt: 0.25,
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.9rem',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
// Allow text to wrap
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{props.gameID === 1 ? 'Bhop' : props.gameID === 2 ? 'Surf' : props.gameID === 5 ? 'Fly Trials' : props.gameID === 4 ? 'Deathrun' : 'Unknown'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
mb: 1.5,
|
||||
}}>
|
||||
<Person2 sx={{
|
||||
mr: 0.75,
|
||||
mt: 0.25,
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.9rem',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
// Allow text to wrap
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{props.author}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar
|
||||
src={`/thumbnails/user/${props.authorId}`}
|
||||
alt={props.author}
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
ml: 1,
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{/*In the future author should be the username of the submitter not the info from the map*/}
|
||||
{props.author} - {new Date(props.created * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function SubmissionInfoPage() {
|
||||
DisplayName: (formData.get("display-name") as string) ?? "unknown", // TEMPORARY! TODO: Change
|
||||
Creator: (formData.get("creator") as string) ?? "unknown", // TEMPORARY! TODO: Change
|
||||
GameID: game,
|
||||
AssetID: Number((formData.get("asset-id") as string) ?? "0"),
|
||||
AssetID: Number((formData.get("asset-id") as string) ?? "-1"),
|
||||
};
|
||||
|
||||
console.log(payload)
|
||||
|
||||
BIN
web/src/app/favicon.ico
Normal file
BIN
web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,9 +1,16 @@
|
||||
'use client';
|
||||
import "./globals.scss";
|
||||
import {theme} from "@/app/lib/theme";
|
||||
import {ThemeProvider} from "@mui/material";
|
||||
|
||||
export default function RootLayout({children}: Readonly<{children: React.ReactNode}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<ThemeProvider theme={theme}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
35
web/src/app/lib/errorImageResponse.ts
Normal file
35
web/src/app/lib/errorImageResponse.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function errorImageResponse(
|
||||
statusCode: number = 500,
|
||||
options?: { message?: string }
|
||||
): Promise<NextResponse> {
|
||||
const file = `${statusCode}.png`;
|
||||
const filePath = path.join(process.cwd(), 'public/errors', file);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'image/png',
|
||||
};
|
||||
if (options?.message) {
|
||||
headers['X-Error-Message'] = encodeURIComponent(options.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
headers['Content-Length'] = buffer.length.toString();
|
||||
return new NextResponse(buffer, {
|
||||
status: statusCode,
|
||||
headers,
|
||||
});
|
||||
} catch {
|
||||
const fallback = path.join(process.cwd(), 'public/errors', '500.png');
|
||||
const buffer = await fs.readFile(fallback);
|
||||
headers['Content-Length'] = buffer.length.toString();
|
||||
return new NextResponse(buffer, {
|
||||
status: 500,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
91
web/src/app/lib/theme.tsx
Normal file
91
web/src/app/lib/theme.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import {createTheme} from "@mui/material";
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#90caf9',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f48fb1',
|
||||
},
|
||||
background: {
|
||||
default: '#121212',
|
||||
paper: '#1e1e1e',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h5: {
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
subtitle1: {
|
||||
fontWeight: 500,
|
||||
fontSize: '0.95rem',
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
caption: {
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardMedia: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 16,
|
||||
'&:last-child': {
|
||||
paddingBottom: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDivider: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -10,6 +10,7 @@ interface ReviewAction {
|
||||
|
||||
const ReviewActions = {
|
||||
Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction,
|
||||
BypassSubmit: {name:"Bypass Submit",action:"bypass-submit"} as ReviewAction,
|
||||
ResetSubmitting: {name:"Reset Submitting (fix softlocked status)",action:"reset-submitting"} as ReviewAction,
|
||||
Revoke: {name:"Revoke",action:"revoke"} as ReviewAction,
|
||||
Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction,
|
||||
@@ -119,6 +120,10 @@ export default function ReviewButtons(props: ReviewId) {
|
||||
}
|
||||
|
||||
if (roles&RolesConstants.MapfixReview) {
|
||||
// you can force submit a map in ChangesRequested status
|
||||
if (!is_submitter && mapfixStatus === MapfixStatus.ChangesRequested) {
|
||||
visibleButtons.push({ action: ReviewActions.BypassSubmit, color: "error", mapfixId });
|
||||
}
|
||||
// you can't review your own mapfix!
|
||||
// note that this means there needs to be more than one person with MapfixReview
|
||||
if (!is_submitter && mapfixStatus === MapfixStatus.Submitted) {
|
||||
|
||||
@@ -2,113 +2,130 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { MapfixList } from "../ts/Mapfix";
|
||||
import { MapfixCard } from "../_components/mapCard";
|
||||
import {MapCard} from "../_components/mapCard";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
|
||||
// TODO: MAKE MAPFIX & SUBMISSIONS USE THE SAME COMPONENTS :angry: (currently too lazy)
|
||||
|
||||
import "./(styles)/page.scss";
|
||||
import { ListSortConstants } from "../ts/Sort";
|
||||
import {Box, Breadcrumbs, CircularProgress, Container, Pagination, Typography} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MapfixInfoPage() {
|
||||
const [mapfixes, setMapfixes] = useState<MapfixList>({Total:0,Mapfixes:[]})
|
||||
const [mapfixes, setMapfixes] = useState<MapfixList|null>(null)
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
|
||||
|
||||
const totalPages = Math.ceil(mapfixes.Total / cardsPerPage);
|
||||
|
||||
const currentCards = mapfixes.Mapfixes.slice(
|
||||
(currentPage - 1) * cardsPerPage,
|
||||
currentPage * cardsPerPage
|
||||
);
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMapfixes() {
|
||||
const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchMapFixes() {
|
||||
setIsLoading(true);
|
||||
const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
setMapfixes(await res.json())
|
||||
setMapfixes(await res.json());
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
fetchMapfixes()
|
||||
}, 50);
|
||||
}, [currentPage])
|
||||
fetchMapFixes();
|
||||
|
||||
if (!mapfixes) {
|
||||
return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes
|
||||
}, [currentPage]);
|
||||
|
||||
if (isLoading || !mapfixes) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Loading...
|
||||
</main>
|
||||
</Webpage>
|
||||
}
|
||||
|
||||
if (mapfixes && mapfixes.Total == 0) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Mapfixes list is empty.
|
||||
</main>
|
||||
</Webpage>
|
||||
}
|
||||
|
||||
return (
|
||||
// TODO: Add filter settings & searchbar & page selector
|
||||
<Webpage>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<div className="pagination-dots">
|
||||
{Array.from({ length: totalPages }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`dot ${index+1 === currentPage ? 'active' : ''}`}
|
||||
onClick={() => setCurrentPage(index+1)}
|
||||
></span>
|
||||
))}
|
||||
</div>
|
||||
<div className="pagination">
|
||||
<button onClick={prevPage} disabled={currentPage === 1}><</button>
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button onClick={nextPage} disabled={currentPage === totalPages}>></button>
|
||||
</div>
|
||||
<div className="grid">
|
||||
{currentCards.map((mapfix) => (
|
||||
<MapfixCard
|
||||
key={mapfix.ID}
|
||||
id={mapfix.ID}
|
||||
assetId={mapfix.AssetID}
|
||||
displayName={mapfix.DisplayName}
|
||||
author={mapfix.Creator}
|
||||
authorId={mapfix.Submitter}
|
||||
rating={mapfix.StatusID}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<CircularProgress/>
|
||||
<Typography variant="body1" style={{marginTop: '1rem'}}>
|
||||
Loading mapfixes...
|
||||
</Typography>
|
||||
</Box>
|
||||
</main>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(mapfixes.Total / cardsPerPage);
|
||||
const currentCards = mapfixes.Mapfixes;
|
||||
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs separator="›" aria-label="breadcrumb"
|
||||
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
|
||||
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
|
||||
<Typography component="span">Home</Typography>
|
||||
</Link>
|
||||
<Typography color="textPrimary">Mapfixes</Typography>
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
|
||||
Map Fixes
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" mb={4}>
|
||||
Explore all submitted fixes for maps from the community.
|
||||
</Typography>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{currentCards.map((submission) => (
|
||||
<MapCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
statusID={submission.StatusID}
|
||||
gameID={submission.GameID}
|
||||
created={submission.CreatedAt}
|
||||
type="mapfix"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Box display="flex" justifyContent="center" my={4}>
|
||||
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={(_, page) => setCurrentPage(page)}
|
||||
variant="outlined"
|
||||
shape="rounded"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</main>
|
||||
</Container>
|
||||
</Webpage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function MapfixInfoPage() {
|
||||
const formData = new FormData(form);
|
||||
|
||||
const payload: MapfixPayload = {
|
||||
AssetID: Number((formData.get("asset-id") as string) ?? "0"),
|
||||
AssetID: Number((formData.get("asset-id") as string) ?? "-1"),
|
||||
TargetAssetID: Number(dynamicId.mapId),
|
||||
Description: (formData.get("description") as string) ?? "unknown", // TEMPORARY! TODO: Change
|
||||
};
|
||||
|
||||
@@ -1,60 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import {useState, useEffect} from "react";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import {useRouter} from "next/navigation";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
|
||||
import "./(styles)/page.scss";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
CardActionArea,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Pagination,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
SelectChangeEvent, Breadcrumbs
|
||||
} from "@mui/material";
|
||||
import {Search as SearchIcon} from "@mui/icons-material";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Map {
|
||||
ID: number;
|
||||
DisplayName: string;
|
||||
Creator: string;
|
||||
GameID: number;
|
||||
Date: number;
|
||||
ID: number;
|
||||
DisplayName: string;
|
||||
Creator: string;
|
||||
GameID: number;
|
||||
Date: number;
|
||||
}
|
||||
|
||||
// TODO: should rewrite this entire page, just wanted to get a simple page working. This was written by chatgippity
|
||||
|
||||
export default function MapsPage() {
|
||||
const [maps, setMaps] = useState<Map[]>([]);
|
||||
const router = useRouter();
|
||||
const [maps, setMaps] = useState<Map[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [gameFilter, setGameFilter] = useState<string>("0"); // 0 means "All Maps"
|
||||
const mapsPerPage = 12;
|
||||
const requestPageSize = 100;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMaps = async () => {
|
||||
const res = await fetch("/api/maps?Page=1&Limit=100");
|
||||
const data: Map[] = await res.json();
|
||||
setMaps(data);
|
||||
};
|
||||
useEffect(() => {
|
||||
const fetchMaps = async () => {
|
||||
// Just send it and load all maps hoping for the best
|
||||
try {
|
||||
setLoading(true);
|
||||
let allMaps: Map[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
fetchMaps();
|
||||
}, []);
|
||||
while (hasMore) {
|
||||
const res = await fetch(`/api/maps?Page=${page}&Limit=${requestPageSize}`);
|
||||
const data: Map[] = await res.json();
|
||||
allMaps = [...allMaps, ...data];
|
||||
hasMore = data.length === requestPageSize;
|
||||
page++;
|
||||
}
|
||||
|
||||
const customLoader = ({ src }: { src: string }) => {
|
||||
return src;
|
||||
};
|
||||
setMaps(allMaps);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch maps:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Webpage>
|
||||
<div className="maps-container">
|
||||
{maps.map((map) => (
|
||||
<div key={map.ID} className="map-card">
|
||||
<a href={`/maps/${map.ID}`} className="block">
|
||||
<Image
|
||||
loader={customLoader}
|
||||
src={`/thumbnails/maps/${map.ID}`}
|
||||
alt={map.DisplayName}
|
||||
width={500}
|
||||
height={300}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<div className="map-info">
|
||||
<h2>{map.DisplayName}</h2>
|
||||
<p>By {map.Creator}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Webpage>
|
||||
);
|
||||
fetchMaps();
|
||||
}, []);
|
||||
|
||||
const handleGameFilterChange = (event: SelectChangeEvent) => {
|
||||
setGameFilter(event.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Filter maps based on search query and game filter
|
||||
const filteredMaps = maps.filter(map => {
|
||||
const matchesSearch =
|
||||
map.DisplayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
map.Creator.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesGameFilter =
|
||||
gameFilter === "0" || // "All Maps"
|
||||
map.GameID === parseInt(gameFilter);
|
||||
|
||||
return matchesSearch && matchesGameFilter;
|
||||
});
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(filteredMaps.length / mapsPerPage);
|
||||
const currentMaps = filteredMaps.slice(
|
||||
(currentPage - 1) * mapsPerPage,
|
||||
currentPage * mapsPerPage
|
||||
);
|
||||
|
||||
const handlePageChange = (_event: React.ChangeEvent<unknown>, page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
};
|
||||
|
||||
const handleMapClick = (mapId: number) => {
|
||||
router.push(`/maps/${mapId}`);
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getGameName = (gameId: number) => {
|
||||
switch (gameId) {
|
||||
case 1:
|
||||
return "Bhop";
|
||||
case 2:
|
||||
return "Surf";
|
||||
case 5:
|
||||
return "Fly Trials";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
const getGameLabelStyles = (gameId: number) => {
|
||||
switch (gameId) {
|
||||
case 1: // Bhop
|
||||
return {
|
||||
bgcolor: "info.main",
|
||||
color: "white",
|
||||
};
|
||||
case 2: // Surf
|
||||
return {
|
||||
bgcolor: "success.main",
|
||||
color: "white",
|
||||
};
|
||||
case 5: // Fly Trials
|
||||
return {
|
||||
bgcolor: "warning.main",
|
||||
color: "white",
|
||||
};
|
||||
default: // Unknown
|
||||
return {
|
||||
bgcolor: "grey.500",
|
||||
color: "white",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{py: 6}}>
|
||||
<Box mb={6}>
|
||||
<Breadcrumbs separator="›" aria-label="breadcrumb"
|
||||
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
|
||||
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
|
||||
<Typography component="span">Home</Typography>
|
||||
</Link>
|
||||
<Typography color="textPrimary">Maps</Typography>
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
|
||||
Map Collection
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" mb={4}>
|
||||
Browse all community-created maps or find your favorites
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Search maps by name or creator..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon/>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{mb: 4}}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" my={8}>
|
||||
<CircularProgress/>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography>
|
||||
Showing {filteredMaps.length} {filteredMaps.length === 1 ? 'map' : 'maps'}
|
||||
</Typography>
|
||||
|
||||
<FormControl sx={{minWidth: 200}}>
|
||||
<InputLabel id="game-filter-label">Filter by Game</InputLabel>
|
||||
<Select
|
||||
labelId="game-filter-label"
|
||||
id="game-filter"
|
||||
value={gameFilter}
|
||||
label="Filter by Game"
|
||||
onChange={handleGameFilterChange}
|
||||
>
|
||||
<MenuItem value="0">All Maps</MenuItem>
|
||||
<MenuItem value="1">Bhop</MenuItem>
|
||||
<MenuItem value="2">Surf</MenuItem>
|
||||
<MenuItem value="5">Fly Trials</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{currentMaps.map((map) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={map.ID}>
|
||||
<Card
|
||||
elevation={1}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 4,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardActionArea onClick={() => handleMapClick(map.ID)}>
|
||||
<CardMedia
|
||||
component="div"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: 180,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={10}
|
||||
right={10}
|
||||
px={1}
|
||||
py={0.5}
|
||||
borderRadius={1}
|
||||
fontSize="0.75rem"
|
||||
fontWeight="bold"
|
||||
{...getGameLabelStyles(map.GameID)}
|
||||
>
|
||||
{getGameName(map.GameID)}
|
||||
</Box>
|
||||
<Image
|
||||
src={`/thumbnails/asset/${map.ID}`}
|
||||
alt={map.DisplayName}
|
||||
fill
|
||||
style={{objectFit: 'cover'}}
|
||||
/>
|
||||
</CardMedia>
|
||||
<CardContent>
|
||||
<Typography variant="h6" component="h2" noWrap>
|
||||
{map.DisplayName}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
By {map.Creator}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Added {formatDate(map.Date)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box display="flex" justifyContent="center" my={4}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
variant="outlined"
|
||||
shape="rounded"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,228 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {MapfixInfo, MapfixList} from "./ts/Mapfix";
|
||||
import { MapCard } from "./_components/mapCard";
|
||||
import Webpage from "./_components/webpage";
|
||||
import { ListSortConstants } from "./ts/Sort";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import {SubmissionInfo, SubmissionList} from "@/app/ts/Submission";
|
||||
import {Carousel} from "@/app/_components/carousel";
|
||||
|
||||
export default function Home() {
|
||||
const [mapfixes, setMapfixes] = useState<MapfixList | null>(null);
|
||||
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
||||
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState<boolean>(false);
|
||||
const [isLoadingSubmissions, setIsLoadingSubmissions] = useState<boolean>(false);
|
||||
const itemsPerSection: number = 8; // Show more items for the carousel
|
||||
|
||||
useEffect(() => {
|
||||
const mapfixController = new AbortController();
|
||||
const submissionsController = new AbortController();
|
||||
|
||||
async function fetchMapFixes(): Promise<void> {
|
||||
setIsLoadingMapfixes(true);
|
||||
try {
|
||||
const res = await fetch(`/api/mapfixes?Page=1&Limit=${itemsPerSection}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: mapfixController.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: MapfixList = await res.json();
|
||||
setMapfixes(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch mapfixes:", error);
|
||||
} finally {
|
||||
setIsLoadingMapfixes(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSubmissions(): Promise<void> {
|
||||
setIsLoadingSubmissions(true);
|
||||
try {
|
||||
const res = await fetch(`/api/submissions?Page=1&Limit=${itemsPerSection}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: submissionsController.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: SubmissionList = await res.json();
|
||||
setSubmissions(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch submissions:", error);
|
||||
} finally {
|
||||
setIsLoadingSubmissions(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchMapFixes();
|
||||
fetchSubmissions();
|
||||
|
||||
return () => {
|
||||
mapfixController.abort();
|
||||
submissionsController.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions;
|
||||
|
||||
if (isLoading && (!mapfixes || !submissions)) {
|
||||
return <Webpage>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<CircularProgress/>
|
||||
<Typography variant="body1" style={{marginTop: '1rem'}}>
|
||||
Loading content...
|
||||
</Typography>
|
||||
</Box>
|
||||
</main>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
const renderMapfixCard = (mapfix: MapfixInfo): React.ReactNode => (
|
||||
<MapCard
|
||||
key={mapfix.ID}
|
||||
id={mapfix.ID}
|
||||
assetId={mapfix.AssetID}
|
||||
displayName={mapfix.DisplayName}
|
||||
author={mapfix.Creator}
|
||||
authorId={mapfix.Submitter}
|
||||
rating={mapfix.StatusID}
|
||||
statusID={mapfix.StatusID}
|
||||
gameID={mapfix.GameID}
|
||||
created={mapfix.CreatedAt}
|
||||
type="mapfix"
|
||||
/>
|
||||
);
|
||||
|
||||
const renderSubmissionCard = (submission: SubmissionInfo): React.ReactNode => (
|
||||
<MapCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
statusID={submission.StatusID}
|
||||
gameID={submission.GameID}
|
||||
created={submission.CreatedAt}
|
||||
type="submission"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Webpage></Webpage>
|
||||
<Webpage>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={5}>
|
||||
Welcome to the Maps Service!
|
||||
</Typography>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 4,
|
||||
mb: 6,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(45deg, #2196F3 30%, #21CBF3 90%)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" component="h2" gutterBottom>
|
||||
Contribute to the community
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Help improve maps by submitting fixes or creating new maps submissions for the community.
|
||||
</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<Link href="/submit" style={{ textDecoration: 'none' }}>
|
||||
<Box
|
||||
component="button"
|
||||
sx={{
|
||||
backgroundColor: 'white',
|
||||
color: '#2196F3',
|
||||
border: 'none',
|
||||
borderRadius: 1,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Submit Map
|
||||
</Box>
|
||||
</Link>
|
||||
<Link href="/maps" style={{ textDecoration: 'none' }}>
|
||||
<Box
|
||||
component="button"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid white',
|
||||
borderRadius: 1,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create Map Fix
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Submissions Carousel */}
|
||||
{submissions && (
|
||||
<Carousel<SubmissionInfo>
|
||||
title="Recent Submissions"
|
||||
items={submissions.Submissions}
|
||||
renderItem={renderSubmissionCard}
|
||||
viewAllLink="/submissions"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Map Fixes Carousel */}
|
||||
{mapfixes && (
|
||||
<Carousel<MapfixInfo>
|
||||
title="Recent Map Fixes"
|
||||
items={mapfixes.Mapfixes}
|
||||
renderItem={renderMapfixCard}
|
||||
viewAllLink="/mapfixes"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</Container>
|
||||
</Webpage>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,8 @@ interface ReviewAction {
|
||||
|
||||
const ReviewActions = {
|
||||
Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction,
|
||||
ForceSubmit: {name:"Force Submit",action:"trigger-submit"} as ReviewAction,
|
||||
AdminSubmit: {name:"Admin Submit",action:"trigger-submit"} as ReviewAction,
|
||||
BypassSubmit: {name:"Bypass Submit",action:"bypass-submit"} as ReviewAction,
|
||||
ResetSubmitting: {name:"Reset Submitting (fix softlocked status)",action:"reset-submitting"} as ReviewAction,
|
||||
Revoke: {name:"Revoke",action:"revoke"} as ReviewAction,
|
||||
Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction,
|
||||
@@ -122,7 +123,8 @@ export default function ReviewButtons(props: ReviewId) {
|
||||
if (roles&RolesConstants.SubmissionReview) {
|
||||
// you can force submit a map in ChangesRequested status
|
||||
if (!is_submitter && submissionStatus === SubmissionStatus.ChangesRequested) {
|
||||
visibleButtons.push({ action: ReviewActions.ForceSubmit, color: "error", submissionId });
|
||||
visibleButtons.push({ action: ReviewActions.AdminSubmit, color: "error", submissionId });
|
||||
visibleButtons.push({ action: ReviewActions.BypassSubmit, color: "error", submissionId });
|
||||
}
|
||||
// you can't review your own submission!
|
||||
// note that this means there needs to be more than one person with SubmissionReview
|
||||
|
||||
@@ -1,112 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { SubmissionList } from "../ts/Submission";
|
||||
import { SubmissionCard } from "../_components/mapCard";
|
||||
import {useState, useEffect} from "react";
|
||||
import {SubmissionList} from "../ts/Submission";
|
||||
import {MapCard} from "../_components/mapCard";
|
||||
import Webpage from "@/app/_components/webpage";
|
||||
|
||||
import "./(styles)/page.scss";
|
||||
import { ListSortConstants } from "../ts/Sort";
|
||||
import {ListSortConstants} from "../ts/Sort";
|
||||
import {Breadcrumbs, Pagination, Typography, CircularProgress, Box, Container} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SubmissionInfoPage() {
|
||||
const [submissions, setSubmissions] = useState<SubmissionList>({Total:0,Submissions:[]})
|
||||
const [submissions, setSubmissions] = useState<SubmissionList | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const cardsPerPage = 24; // built to fit on a 1920x1080 monitor
|
||||
|
||||
const totalPages = Math.ceil(submissions.Total / cardsPerPage);
|
||||
|
||||
const currentCards = submissions.Submissions.slice(
|
||||
(currentPage - 1) * cardsPerPage,
|
||||
currentPage * cardsPerPage
|
||||
);
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchSubmissions() {
|
||||
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`)
|
||||
setIsLoading(true);
|
||||
const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
setSubmissions(await res.json())
|
||||
setSubmissions(await res.json());
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
fetchSubmissions()
|
||||
}, 50);
|
||||
}, [currentPage])
|
||||
fetchSubmissions();
|
||||
|
||||
if (!submissions) {
|
||||
return () => controller.abort(); // Cleanup to avoid fetch conflicts on rapid page changes
|
||||
}, [currentPage]);
|
||||
|
||||
if (isLoading || !submissions) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Loading...
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<CircularProgress/>
|
||||
<Typography variant="body1" style={{marginTop: '1rem'}}>
|
||||
Loading submissions...
|
||||
</Typography>
|
||||
</Box>
|
||||
</main>
|
||||
</Webpage>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
if (submissions && submissions.Total == 0) {
|
||||
const totalPages = Math.ceil(submissions.Total / cardsPerPage);
|
||||
const currentCards = submissions.Submissions;
|
||||
|
||||
if (submissions.Total === 0) {
|
||||
return <Webpage>
|
||||
<main>
|
||||
Submissions list is empty.
|
||||
</main>
|
||||
</Webpage>
|
||||
</Webpage>;
|
||||
}
|
||||
|
||||
return (
|
||||
// TODO: Add filter settings & searchbar & page selector
|
||||
<Webpage>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div className="pagination-dots">
|
||||
{Array.from({ length: totalPages }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`dot ${index+1 === currentPage ? 'active' : ''}`}
|
||||
onClick={() => setCurrentPage(index+1)}
|
||||
></span>
|
||||
))}
|
||||
</div>
|
||||
<div className="pagination">
|
||||
<button onClick={prevPage} disabled={currentPage === 1}><</button>
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button onClick={nextPage} disabled={currentPage === totalPages}>></button>
|
||||
</div>
|
||||
<div className="grid">
|
||||
{currentCards.map((submission) => (
|
||||
<SubmissionCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<main
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs separator="›" aria-label="breadcrumb"
|
||||
style={{alignSelf: 'flex-start', marginBottom: '1rem'}}>
|
||||
<Link href="/" style={{textDecoration: 'none', color: 'inherit'}}>
|
||||
<Typography component="span">Home</Typography>
|
||||
</Link>
|
||||
<Typography color="textPrimary">Submissions</Typography>
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h3" component="h1" fontWeight="bold" mb={2}>
|
||||
Submissions
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" mb={4}>
|
||||
Explore all submitted maps from the community.
|
||||
</Typography>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{currentCards.map((submission) => (
|
||||
<MapCard
|
||||
key={submission.ID}
|
||||
id={submission.ID}
|
||||
assetId={submission.AssetID}
|
||||
displayName={submission.DisplayName}
|
||||
author={submission.Creator}
|
||||
authorId={submission.Submitter}
|
||||
rating={submission.StatusID}
|
||||
statusID={submission.StatusID}
|
||||
gameID={submission.GameID}
|
||||
created={submission.CreatedAt}
|
||||
type="submission"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Box display="flex" justifyContent="center" my={4}>
|
||||
<div style={{marginTop: '1rem', marginBottom: '1rem'}}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={(_, page) => setCurrentPage(page)}
|
||||
variant="outlined"
|
||||
shape="rounded"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</main>
|
||||
</Container>
|
||||
</Webpage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function SubmissionInfoPage() {
|
||||
DisplayName: (formData.get("display-name") as string) ?? "unknown", // TEMPORARY! TODO: Change
|
||||
Creator: (formData.get("creator") as string) ?? "unknown", // TEMPORARY! TODO: Change
|
||||
GameID: game,
|
||||
AssetID: Number((formData.get("asset-id") as string) ?? "0"),
|
||||
AssetID: Number((formData.get("asset-id") as string) ?? "-1"),
|
||||
};
|
||||
|
||||
console.log(payload)
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { errorImageResponse } from '@/app/lib/errorImageResponse';
|
||||
|
||||
const cache = new Map<number, { buffer: Buffer; expires: number }>();
|
||||
const CACHE_TTL = 15 * 60 * 1000;
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of cache.entries()) {
|
||||
if (value.expires <= now) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60 * 5 * 1000);
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -7,10 +20,9 @@ export async function GET(
|
||||
const { assetId } = await context.params;
|
||||
|
||||
if (!assetId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing asset ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
return errorImageResponse(400, {
|
||||
message: "Missing asset ID",
|
||||
})
|
||||
}
|
||||
|
||||
let finalAssetId = assetId;
|
||||
@@ -27,43 +39,57 @@ export async function GET(
|
||||
}
|
||||
} catch { }
|
||||
|
||||
const now = Date.now();
|
||||
const cached = cache.get(finalAssetId);
|
||||
|
||||
if (cached && cached.expires > now) {
|
||||
return new NextResponse(cached.buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': cached.buffer.length.toString(),
|
||||
'Cache-Control': `public, max-age=${CACHE_TTL / 1000}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${finalAssetId}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch thumbnail JSON');
|
||||
throw new Error(`Failed to fetch thumbnail JSON [${response.status}]`)
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const imageUrl = data.data[0]?.imageUrl;
|
||||
if (!imageUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No image URL found in the response' },
|
||||
{ status: 404 }
|
||||
);
|
||||
return errorImageResponse(404, {
|
||||
message: "No image URL found in the response",
|
||||
})
|
||||
}
|
||||
|
||||
const imageResponse = await fetch(imageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error('Failed to fetch the image');
|
||||
throw new Error(`Failed to fetch the image [${imageResponse.status}]`)
|
||||
}
|
||||
|
||||
const arrayBuffer = await imageResponse.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
cache.set(finalAssetId, { buffer, expires: now + CACHE_TTL });
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': buffer.length.toString(),
|
||||
'Cache-Control': `public, max-age=${CACHE_TTL / 1000}`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch or process thumbnail' },
|
||||
{ status: 500 }
|
||||
);
|
||||
} catch (err) {
|
||||
return errorImageResponse(500, {
|
||||
message: `Failed to fetch or process thumbnail: ${err}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -6,8 +6,15 @@ export async function GET(
|
||||
): Promise<NextResponse> {
|
||||
// TODO: implement this, we need a cdn for in-game map thumbnails...
|
||||
|
||||
const { mapId } = await context.params;
|
||||
const baseUrl = request.nextUrl.origin; // Gets the current base URL
|
||||
if (!process.env.API_HOST) {
|
||||
throw new Error('env variable "API_HOST" is not set')
|
||||
}
|
||||
|
||||
const { mapId } = await context.params
|
||||
|
||||
return NextResponse.redirect(`${baseUrl}/thumbnails/asset/${mapId}`);
|
||||
const apiHost = process.env.API_HOST.replace(/\/api\/?$/, "")
|
||||
const redirectPath = `/thumbnails/asset/${mapId}`
|
||||
const redirectUrl = `${apiHost}${redirectPath}`
|
||||
|
||||
return NextResponse.redirect(redirectUrl)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ interface MapfixInfo {
|
||||
readonly DisplayName: string,
|
||||
readonly Creator: string,
|
||||
readonly GameID: number,
|
||||
readonly Date: number,
|
||||
readonly CreatedAt: number,
|
||||
readonly Submitter: number,
|
||||
readonly AssetID: number,
|
||||
readonly AssetVersion: number,
|
||||
|
||||
@@ -17,7 +17,7 @@ interface SubmissionInfo {
|
||||
readonly DisplayName: string,
|
||||
readonly Creator: string,
|
||||
readonly GameID: number,
|
||||
readonly Date: number,
|
||||
readonly CreatedAt: number,
|
||||
readonly Submitter: number,
|
||||
readonly AssetID: number,
|
||||
readonly AssetVersion: number,
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export const config = {
|
||||
matcher: ["/api/:path*", "/auth/:path*"],
|
||||
matcher: ["/api/:path*", "/auth/:path*"],
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname, search } = request.nextUrl
|
||||
const { pathname, search } = request.nextUrl
|
||||
|
||||
if (pathname.startsWith("/api")) {
|
||||
if (!process.env.API_HOST) {
|
||||
throw new Error('env variable "API_HOST" is not set')
|
||||
}
|
||||
const apiUrl = new URL(process.env.API_HOST + pathname.replace(/^\/api/, '') + search)
|
||||
return NextResponse.rewrite(apiUrl, { request })
|
||||
} else if (pathname.startsWith("/auth")) {
|
||||
if (pathname.startsWith("/api")) {
|
||||
if (!process.env.API_HOST) {
|
||||
throw new Error('env variable "API_HOST" is not set')
|
||||
}
|
||||
|
||||
const baseUrl = process.env.API_HOST.replace(/\/$/, "");
|
||||
const path = pathname.replace(/^\/api/, "");
|
||||
const apiUrl = new URL(baseUrl + path + search);
|
||||
|
||||
return NextResponse.rewrite(apiUrl, { request });
|
||||
} else if (pathname.startsWith("/auth")) {
|
||||
if (!process.env.AUTH_HOST) {
|
||||
throw new Error('env variable "AUTH_HOST" is not set')
|
||||
}
|
||||
|
||||
const authHost = process.env.AUTH_HOST.replace(/\/$/, "")
|
||||
const path = pathname.replace(/^\/auth/, "")
|
||||
const redirectUrl = new URL(authHost + path + search)
|
||||
|
||||
return NextResponse.redirect(redirectUrl, 302)
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
const authHost = process.env.AUTH_HOST.replace(/\/$/, "");
|
||||
const path = pathname.replace(/^\/auth/, "");
|
||||
const redirectUrl = new URL(authHost + path + search);
|
||||
|
||||
return NextResponse.redirect(redirectUrl, 302);
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user