Compare commits

..

25 Commits

Author SHA1 Message Date
a9afdf38cf
web: auth redirect fix 2025-03-26 16:27:32 -07:00
d3edb6b3da
validation: include script path in ScriptFlaggedIllegalKeyword 2025-03-26 16:23:44 -07:00
188fbd2a6d
submissions: rename VersionID to ValidatedModelVersion 2025-03-26 15:41:46 -07:00
1468a9edc2
openapi: generate 2025-03-26 15:41:19 -07:00
1053719eab
openapi: rename field 2025-03-26 15:40:57 -07:00
2867da4b21
submissions: detect sentinel value 2025-03-26 15:33:47 -07:00
85a144e276
submissions-api: v0.6.1 2025-03-26 14:58:28 -07:00
4227f18992 validator: name model correctly 2025-03-26 21:30:12 +00:00
123bc8af47 validator: write and use tragic script name function 2025-03-26 21:30:12 +00:00
cd82954b73 validator: refactor errors to improve information and clarity 2025-03-26 21:30:12 +00:00
ce08b57e18 submissions-api: include get_scripts & get_script_from_hash in internal api 2025-03-26 21:30:12 +00:00
1ca0348924 submissions-api: derive Clone, Debug on many types 2025-03-26 21:30:12 +00:00
936a1f93aa web: use --turbopack for dev 2025-03-26 21:29:07 +00:00
d5d0e5ffc9 web: redirect if the user is not logged in based on session_id cookie's presence 2025-03-26 21:29:07 +00:00
039309c75a
submissions: include status message 2025-03-26 13:08:56 -07:00
7cc0b5da7f
openapi: generate 2025-03-26 13:08:41 -07:00
f0c44fb4a8
openapi: include status message 2025-03-26 13:08:22 -07:00
4fec1bba47
validation: do not implicitly append url 2025-03-26 12:53:35 -07:00
5ae287f3f2
docker: fix API_HOST 2025-03-26 12:46:54 -07:00
bf6c8af21a
docker: add group id env var 2025-03-26 12:34:27 -07:00
65e63431a3
docker: use staging auth image 2025-03-26 12:26:37 -07:00
a8dc6cd35a
submissions: introduce new role SubmissionRelease 2025-03-26 12:07:06 -07:00
539e09fe06
validator: correct enum item name 2025-03-26 12:07:06 -07:00
87fd7adb93
submissions: rename SubmissionPublish to SubmissionUpload 2025-03-26 12:07:06 -07:00
7d57d1ac4d
submissions: improve error granularity 2025-03-26 12:07:06 -07:00
20 changed files with 257 additions and 93 deletions

2
Cargo.lock generated

@ -1833,7 +1833,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]] [[package]]
name = "submissions-api" name = "submissions-api"
version = "0.6.0" version = "0.6.1"
dependencies = [ dependencies = [
"reqwest", "reqwest",
"serde", "serde",

@ -49,7 +49,7 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- API_HOST=http://submissions:8082 - API_HOST=http://submissions:8082/v1
validation: validation:
image: image:
@ -59,7 +59,7 @@ services:
- ../auth-compose/strafesnet_staging.env - ../auth-compose/strafesnet_staging.env
environment: environment:
- ROBLOX_GROUP_ID=17032139 # "None" is special case string value - ROBLOX_GROUP_ID=17032139 # "None" is special case string value
- API_HOST_INTERNAL=http://submissions:8083 - API_HOST_INTERNAL=http://submissions:8083/v1
- NATS_HOST=nats:4222 - NATS_HOST=nats:4222
depends_on: depends_on:
- nats - nats
@ -93,11 +93,12 @@ services:
- maps-service-network - maps-service-network
authrpc: authrpc:
image: registry.itzana.me/strafesnet/auth-service:master image: registry.itzana.me/strafesnet/auth-service:staging
container_name: authrpc container_name: authrpc
command: ["serve", "rpc"] command: ["serve", "rpc"]
environment: environment:
- REDIS_ADDR=authredis:6379 - REDIS_ADDR=authredis:6379
- RBX_GROUP_ID=17032139
env_file: env_file:
- ../auth-compose/auth-service.env - ../auth-compose/auth-service.env
depends_on: depends_on:
@ -108,7 +109,7 @@ services:
driver: "none" driver: "none"
auth-web: auth-web:
image: registry.itzana.me/strafesnet/auth-service:master image: registry.itzana.me/strafesnet/auth-service:staging
command: ["serve", "web"] command: ["serve", "web"]
environment: environment:
- REDIS_ADDR=authredis:6379 - REDIS_ADDR=authredis:6379

@ -21,7 +21,7 @@ paths:
schema: schema:
type: integer type: integer
format: int64 format: int64
- name: VersionID - name: ValidatedModelVersion
in: query in: query
required: true required: true
schema: schema:

@ -717,6 +717,7 @@ components:
- SubmissionType - SubmissionType
# - TargetAssetID # - TargetAssetID
- StatusID - StatusID
- StatusMessage
type: object type: object
properties: properties:
ID: ID:
@ -757,6 +758,9 @@ components:
StatusID: StatusID:
type: integer type: integer
format: int32 format: int32
StatusMessage:
type: string
maxLength: 256
SubmissionCreate: SubmissionCreate:
required: required:
- DisplayName - DisplayName

@ -1464,9 +1464,13 @@ func (s *Submission) encodeFields(e *jx.Encoder) {
e.FieldStart("StatusID") e.FieldStart("StatusID")
e.Int32(s.StatusID) e.Int32(s.StatusID)
} }
{
e.FieldStart("StatusMessage")
e.Str(s.StatusMessage)
}
} }
var jsonFieldsNameOfSubmission = [13]string{ var jsonFieldsNameOfSubmission = [14]string{
0: "ID", 0: "ID",
1: "DisplayName", 1: "DisplayName",
2: "Creator", 2: "Creator",
@ -1480,6 +1484,7 @@ var jsonFieldsNameOfSubmission = [13]string{
10: "SubmissionType", 10: "SubmissionType",
11: "TargetAssetID", 11: "TargetAssetID",
12: "StatusID", 12: "StatusID",
13: "StatusMessage",
} }
// Decode decodes Submission from json. // Decode decodes Submission from json.
@ -1645,6 +1650,18 @@ func (s *Submission) Decode(d *jx.Decoder) error {
}(); err != nil { }(); err != nil {
return errors.Wrap(err, "decode field \"StatusID\"") return errors.Wrap(err, "decode field \"StatusID\"")
} }
case "StatusMessage":
requiredBitSet[1] |= 1 << 5
if err := func() error {
v, err := d.Str()
s.StatusMessage = string(v)
if err != nil {
return err
}
return nil
}(); err != nil {
return errors.Wrap(err, "decode field \"StatusMessage\"")
}
default: default:
return d.Skip() return d.Skip()
} }
@ -1656,7 +1673,7 @@ func (s *Submission) Decode(d *jx.Decoder) error {
var failures []validate.FieldError var failures []validate.FieldError
for i, mask := range [2]uint8{ for i, mask := range [2]uint8{
0b11111111, 0b11111111,
0b00010111, 0b00110111,
} { } {
if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 {
// Mask only required fields and check equality to mask using XOR. // Mask only required fields and check equality to mask using XOR.

@ -600,6 +600,7 @@ type Submission struct {
SubmissionType int32 `json:"SubmissionType"` SubmissionType int32 `json:"SubmissionType"`
TargetAssetID OptInt64 `json:"TargetAssetID"` TargetAssetID OptInt64 `json:"TargetAssetID"`
StatusID int32 `json:"StatusID"` StatusID int32 `json:"StatusID"`
StatusMessage string `json:"StatusMessage"`
} }
// GetID returns the value of ID. // GetID returns the value of ID.
@ -667,6 +668,11 @@ func (s *Submission) GetStatusID() int32 {
return s.StatusID return s.StatusID
} }
// GetStatusMessage returns the value of StatusMessage.
func (s *Submission) GetStatusMessage() string {
return s.StatusMessage
}
// SetID sets the value of ID. // SetID sets the value of ID.
func (s *Submission) SetID(val int64) { func (s *Submission) SetID(val int64) {
s.ID = val s.ID = val
@ -732,6 +738,11 @@ func (s *Submission) SetStatusID(val int32) {
s.StatusID = val s.StatusID = val
} }
// SetStatusMessage sets the value of StatusMessage.
func (s *Submission) SetStatusMessage(val string) {
s.StatusMessage = val
}
// Ref: #/components/schemas/SubmissionCreate // Ref: #/components/schemas/SubmissionCreate
type SubmissionCreate struct { type SubmissionCreate struct {
DisplayName string `json:"DisplayName"` DisplayName string `json:"DisplayName"`

@ -266,6 +266,25 @@ func (s *Submission) Validate() error {
Error: err, Error: err,
}) })
} }
if err := func() error {
if err := (validate.String{
MinLength: 0,
MinLengthSet: false,
MaxLength: 256,
MaxLengthSet: true,
Email: false,
Hostname: false,
Regex: nil,
}).Validate(string(s.StatusMessage)); err != nil {
return errors.Wrap(err, "string")
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "StatusMessage",
Error: err,
})
}
if len(failures) > 0 { if len(failures) > 0 {
return &validate.Error{Fields: failures} return &validate.Error{Fields: failures}
} }

@ -1096,15 +1096,15 @@ func (c *Client) sendUpdateSubmissionValidatedModel(ctx context.Context, params
} }
} }
{ {
// Encode "VersionID" parameter. // Encode "ValidatedModelVersion" parameter.
cfg := uri.QueryParameterEncodingConfig{ cfg := uri.QueryParameterEncodingConfig{
Name: "VersionID", Name: "ValidatedModelVersion",
Style: uri.QueryStyleForm, Style: uri.QueryStyleForm,
Explode: true, Explode: true,
} }
if err := q.EncodeParam(cfg, func(e uri.Encoder) error { if err := q.EncodeParam(cfg, func(e uri.Encoder) error {
return e.EncodeValue(conv.Int64ToString(params.VersionID)) return e.EncodeValue(conv.Int64ToString(params.ValidatedModelVersion))
}); err != nil { }); err != nil {
return res, errors.Wrap(err, "encode query") return res, errors.Wrap(err, "encode query")
} }

@ -1369,9 +1369,9 @@ func (s *Server) handleUpdateSubmissionValidatedModelRequest(args [1]string, arg
In: "query", In: "query",
}: params.ValidatedModelID, }: params.ValidatedModelID,
{ {
Name: "VersionID", Name: "ValidatedModelVersion",
In: "query", In: "query",
}: params.VersionID, }: params.ValidatedModelVersion,
}, },
Raw: r, Raw: r,
} }

@ -1114,9 +1114,9 @@ func decodeListScriptsParams(args [0]string, argsEscaped bool, r *http.Request)
// UpdateSubmissionValidatedModelParams is parameters of updateSubmissionValidatedModel operation. // UpdateSubmissionValidatedModelParams is parameters of updateSubmissionValidatedModel operation.
type UpdateSubmissionValidatedModelParams struct { type UpdateSubmissionValidatedModelParams struct {
// The unique identifier for a submission. // The unique identifier for a submission.
SubmissionID int64 SubmissionID int64
ValidatedModelID int64 ValidatedModelID int64
VersionID int64 ValidatedModelVersion int64
} }
func unpackUpdateSubmissionValidatedModelParams(packed middleware.Parameters) (params UpdateSubmissionValidatedModelParams) { func unpackUpdateSubmissionValidatedModelParams(packed middleware.Parameters) (params UpdateSubmissionValidatedModelParams) {
@ -1136,10 +1136,10 @@ func unpackUpdateSubmissionValidatedModelParams(packed middleware.Parameters) (p
} }
{ {
key := middleware.ParameterKey{ key := middleware.ParameterKey{
Name: "VersionID", Name: "ValidatedModelVersion",
In: "query", In: "query",
} }
params.VersionID = packed[key].(int64) params.ValidatedModelVersion = packed[key].(int64)
} }
return params return params
} }
@ -1227,10 +1227,10 @@ func decodeUpdateSubmissionValidatedModelParams(args [1]string, argsEscaped bool
Err: err, Err: err,
} }
} }
// Decode query: VersionID. // Decode query: ValidatedModelVersion.
if err := func() error { if err := func() error {
cfg := uri.QueryParameterDecodingConfig{ cfg := uri.QueryParameterDecodingConfig{
Name: "VersionID", Name: "ValidatedModelVersion",
Style: uri.QueryStyleForm, Style: uri.QueryStyleForm,
Explode: true, Explode: true,
} }
@ -1247,7 +1247,7 @@ func decodeUpdateSubmissionValidatedModelParams(args [1]string, argsEscaped bool
return err return err
} }
params.VersionID = c params.ValidatedModelVersion = c
return nil return nil
}); err != nil { }); err != nil {
return err return err
@ -1258,7 +1258,7 @@ func decodeUpdateSubmissionValidatedModelParams(args [1]string, argsEscaped bool
return nil return nil
}(); err != nil { }(); err != nil {
return params, &ogenerrors.DecodeParamError{ return params, &ogenerrors.DecodeParamError{
Name: "VersionID", Name: "ValidatedModelVersion",
In: "query", In: "query",
Err: err, Err: err,
} }

@ -10,7 +10,7 @@ type ValidateRequest struct {
SubmissionID int64 SubmissionID int64
ModelID int64 ModelID int64
ModelVersion int64 ModelVersion int64
ValidatedModelID int64 // optional value ValidatedModelID *int64 // optional value
} }
// Create a new map // Create a new map

@ -17,10 +17,11 @@ var (
// Submissions roles bitflag // Submissions roles bitflag
type Roles int32 type Roles int32
var ( var (
RolesScriptWrite Roles = 8 RolesSubmissionRelease Roles = 1<<4
RolesSubmissionPublish Roles = 4 RolesScriptWrite Roles = 1<<3
RolesSubmissionReview Roles = 2 RolesSubmissionUpload Roles = 1<<2
RolesMapDownload Roles = 1 RolesSubmissionReview Roles = 1<<1
RolesMapDownload Roles = 1<<0
RolesEmpty Roles = 0 RolesEmpty Roles = 0
) )
@ -31,10 +32,10 @@ var (
RoleQuat GroupRole = 255 RoleQuat GroupRole = 255
RoleItzaname GroupRole = 254 RoleItzaname GroupRole = 254
RoleStagingDeveloper GroupRole = 240 RoleStagingDeveloper GroupRole = 240
RolesAll Roles = RolesScriptWrite|RolesSubmissionPublish|RolesSubmissionReview|RolesMapDownload RolesAll Roles = RolesScriptWrite|RolesSubmissionRelease|RolesSubmissionUpload|RolesSubmissionReview|RolesMapDownload
// has SubmissionPublish // has SubmissionUpload
RoleMapAdmin GroupRole = 128 RoleMapAdmin GroupRole = 128
RolesMapAdmin Roles = RolesSubmissionPublish|RolesSubmissionReview|RolesMapDownload RolesMapAdmin Roles = RolesSubmissionRelease|RolesSubmissionUpload|RolesSubmissionReview|RolesMapDownload
// has SubmissionReview // has SubmissionReview
RoleMapCouncil GroupRole = 64 RoleMapCouncil GroupRole = 64
RolesMapCouncil Roles = RolesSubmissionReview|RolesMapDownload RolesMapCouncil Roles = RolesSubmissionReview|RolesMapDownload
@ -127,9 +128,11 @@ func (usr UserInfoHandle) GetRoles() (Roles, error) {
} }
// RoleThumbnail // RoleThumbnail
// RoleMapDownload func (usr UserInfoHandle) HasRoleSubmissionRelease() (bool, error) {
func (usr UserInfoHandle) HasRoleSubmissionPublish() (bool, error) { return usr.hasRoles(RolesSubmissionRelease)
return usr.hasRoles(RolesSubmissionPublish) }
func (usr UserInfoHandle) HasRoleSubmissionUpload() (bool, error) {
return usr.hasRoles(RolesSubmissionUpload)
} }
func (usr UserInfoHandle) HasRoleSubmissionReview() (bool, error) { func (usr UserInfoHandle) HasRoleSubmissionReview() (bool, error) {
return usr.hasRoles(RolesSubmissionReview) return usr.hasRoles(RolesSubmissionReview)

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"time" "time"
"git.itzana.me/strafesnet/go-grpc/maps" "git.itzana.me/strafesnet/go-grpc/maps"
@ -37,6 +38,15 @@ var (
ErrActiveSubmissionSameTargetAssetID = errors.New("There is an active submission with the same TargetAssetID") ErrActiveSubmissionSameTargetAssetID = errors.New("There is an active submission with the same TargetAssetID")
ErrReleaseInvalidStatus = errors.New("Only submissions with Uploaded status can be released") ErrReleaseInvalidStatus = errors.New("Only submissions with Uploaded status can be released")
ErrReleaseNoTargetAssetID = errors.New("Only submissions with a TargetAssetID can be released") ErrReleaseNoTargetAssetID = errors.New("Only submissions with a TargetAssetID can be released")
ErrAcceptOwnSubmission = fmt.Errorf("%w: You cannot accept your own submission as the submitter", ErrPermissionDenied)
ErrDelayReset = errors.New("Please give the validator at least 10 seconds to operate before attempting to reset the status")
ErrPermissionDeniedNotSubmitter = fmt.Errorf("%w: You must be the submitter to perform this action", ErrPermissionDenied)
ErrPermissionDeniedNeedSubmissionRelease = fmt.Errorf("%w: Need Role SubmissionRelease", ErrPermissionDenied)
ErrPermissionDeniedNeedSubmissionUpload = fmt.Errorf("%w: Need Role SubmissionUpload", ErrPermissionDenied)
ErrPermissionDeniedNeedRoleSubmissionReview = fmt.Errorf("%w: Need Role SubmissionReview", ErrPermissionDenied)
ErrPermissionDeniedNeedRoleMapDownload = fmt.Errorf("%w: Need Role MapDownload", ErrPermissionDenied)
ErrPermissionDeniedNeedRoleScriptWrite = fmt.Errorf("%w: Need Role ScriptWrite", ErrPermissionDenied)
ErrPermissionDeniedNeedRoleMaptest = fmt.Errorf("%w: Need Role Maptest", ErrPermissionDenied)
) )
// POST /submissions // POST /submissions
@ -147,6 +157,7 @@ func (svc *Service) GetSubmission(ctx context.Context, params api.GetSubmissionP
Completed: submission.Completed, Completed: submission.Completed,
TargetAssetID: api.NewOptInt64(int64(submission.TargetAssetID)), TargetAssetID: api.NewOptInt64(int64(submission.TargetAssetID)),
StatusID: int32(submission.StatusID), StatusID: int32(submission.StatusID),
StatusMessage: submission.StatusMessage,
}, nil }, nil
} }
@ -216,7 +227,7 @@ func (svc *Service) SetSubmissionCompleted(ctx context.Context, params api.SetSu
} }
// check if caller has MaptestGame role (request must originate from a maptest roblox game) // check if caller has MaptestGame role (request must originate from a maptest roblox game)
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNeedRoleMaptest
} }
pmap := datastore.Optional() pmap := datastore.Optional()
@ -247,7 +258,7 @@ func (svc *Service) UpdateSubmissionModel(ctx context.Context, params api.Update
} }
// check if caller is the submitter // check if caller is the submitter
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNotSubmitter
} }
// check if Status is ChangesRequested|Submitted|UnderConstruction // check if Status is ChangesRequested|Submitted|UnderConstruction
@ -276,7 +287,7 @@ func (svc *Service) ActionSubmissionReject(ctx context.Context, params api.Actio
} }
// check if caller has required role // check if caller has required role
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNeedRoleSubmissionReview
} }
// transaction // transaction
@ -302,7 +313,7 @@ func (svc *Service) ActionSubmissionRequestChanges(ctx context.Context, params a
} }
// check if caller has required role // check if caller has required role
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNeedRoleSubmissionReview
} }
// transaction // transaction
@ -334,7 +345,7 @@ func (svc *Service) ActionSubmissionRevoke(ctx context.Context, params api.Actio
} }
// check if caller is the submitter // check if caller is the submitter
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNotSubmitter
} }
// transaction // transaction
@ -366,7 +377,7 @@ func (svc *Service) ActionSubmissionSubmit(ctx context.Context, params api.Actio
} }
// check if caller is the submitter // check if caller is the submitter
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNotSubmitter
} }
// transaction // transaction
@ -386,13 +397,13 @@ func (svc *Service) ActionSubmissionTriggerUpload(ctx context.Context, params ap
return ErrUserInfo return ErrUserInfo
} }
has_role, err := userInfo.HasRoleSubmissionPublish() has_role, err := userInfo.HasRoleSubmissionUpload()
if err != nil { if err != nil {
return err return err
} }
// check if caller has required role // check if caller has required role
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNeedSubmissionUpload
} }
// transaction // transaction
@ -451,13 +462,13 @@ func (svc *Service) ActionSubmissionValidated(ctx context.Context, params api.Ac
return ErrUserInfo return ErrUserInfo
} }
has_role, err := userInfo.HasRoleSubmissionPublish() has_role, err := userInfo.HasRoleSubmissionUpload()
if err != nil { if err != nil {
return err return err
} }
// check if caller has required role // check if caller has required role
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNeedSubmissionUpload
} }
// check when submission was updated // check when submission was updated
@ -467,7 +478,7 @@ func (svc *Service) ActionSubmissionValidated(ctx context.Context, params api.Ac
} }
if time.Now().Before(submission.UpdatedAt.Add(time.Second*10)) { if time.Now().Before(submission.UpdatedAt.Add(time.Second*10)) {
// the last time the submission was updated must be longer than 10 seconds ago // the last time the submission was updated must be longer than 10 seconds ago
return ErrPermissionDenied return ErrDelayReset
} }
// transaction // transaction
@ -493,7 +504,7 @@ func (svc *Service) ActionSubmissionTriggerValidate(ctx context.Context, params
} }
// check if caller has required role // check if caller has required role
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNeedRoleSubmissionReview
} }
// read submission (this could be done with a transaction WHERE clause) // read submission (this could be done with a transaction WHERE clause)
@ -508,7 +519,7 @@ func (svc *Service) ActionSubmissionTriggerValidate(ctx context.Context, params
} }
// check if caller is NOT the submitter // check if caller is NOT the submitter
if has_role { if has_role {
return ErrPermissionDenied return ErrAcceptOwnSubmission
} }
// transaction // transaction
@ -523,7 +534,12 @@ func (svc *Service) ActionSubmissionTriggerValidate(ctx context.Context, params
SubmissionID: submission.ID, SubmissionID: submission.ID,
ModelID: submission.AssetID, ModelID: submission.AssetID,
ModelVersion: submission.AssetVersion, ModelVersion: submission.AssetVersion,
ValidatedModelID: submission.ValidatedAssetID, ValidatedModelID: nil,
}
// sentinel values because we're not using rust
if submission.ValidatedAssetID != 0 {
validate_request.ValidatedModelID = &submission.ValidatedAssetID
} }
j, err := json.Marshal(validate_request) j, err := json.Marshal(validate_request)
@ -553,7 +569,7 @@ func (svc *Service) ActionSubmissionAccepted(ctx context.Context, params api.Act
} }
// check if caller has required role // check if caller has required role
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNeedRoleSubmissionReview
} }
// check when submission was updated // check when submission was updated
@ -563,7 +579,7 @@ func (svc *Service) ActionSubmissionAccepted(ctx context.Context, params api.Act
} }
if time.Now().Before(submission.UpdatedAt.Add(time.Second*10)) { if time.Now().Before(submission.UpdatedAt.Add(time.Second*10)) {
// the last time the submission was updated must be longer than 10 seconds ago // the last time the submission was updated must be longer than 10 seconds ago
return ErrPermissionDenied return ErrDelayReset
} }
// transaction // transaction
@ -584,13 +600,13 @@ func (svc *Service) ReleaseSubmissions(ctx context.Context, request []api.Releas
return ErrUserInfo return ErrUserInfo
} }
has_role, err := userInfo.HasRoleSubmissionPublish() has_role, err := userInfo.HasRoleSubmissionRelease()
if err != nil { if err != nil {
return err return err
} }
// check if caller has required role // check if caller has required role
if !has_role { if !has_role {
return ErrPermissionDenied return ErrPermissionDeniedNeedSubmissionRelease
} }
idList := make([]int64, len(request)) idList := make([]int64, len(request))

@ -17,7 +17,7 @@ func (svc *Service) UpdateSubmissionValidatedModel(ctx context.Context, params i
// check if Status is ChangesRequested|Submitted|UnderConstruction // check if Status is ChangesRequested|Submitted|UnderConstruction
pmap := datastore.Optional() pmap := datastore.Optional()
pmap.AddNotNil("validated_asset_id", params.ValidatedModelID) pmap.AddNotNil("validated_asset_id", params.ValidatedModelID)
pmap.AddNotNil("validated_asset_version", params.VersionID) pmap.AddNotNil("validated_asset_version", params.ValidatedModelVersion)
// DO NOT reset completed when validated model is updated // DO NOT reset completed when validated model is updated
// pmap.Add("completed", false) // pmap.Add("completed", false)
return svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.Status{model.StatusValidating}, pmap) return svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.Status{model.StatusValidating}, pmap)

@ -1,6 +1,6 @@
[package] [package]
name = "submissions-api" name = "submissions-api"
version = "0.6.0" version = "0.6.1"
edition = "2021" edition = "2021"
publish = ["strafesnet"] publish = ["strafesnet"]
repository = "https://git.itzana.me/StrafesNET/maps-service" repository = "https://git.itzana.me/StrafesNET/maps-service"

@ -31,6 +31,47 @@ impl Context{
).await.map_err(Error::Response)? ).await.map_err(Error::Response)?
.json().await.map_err(Error::Reqwest) .json().await.map_err(Error::Reqwest)
} }
pub async fn get_scripts<'a>(&self,config:GetScriptsRequest<'a>)->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)?;
{
let mut query_pairs=url.query_pairs_mut();
query_pairs.append_pair("Page",config.Page.to_string().as_str());
query_pairs.append_pair("Limit",config.Limit.to_string().as_str());
if let Some(name)=config.Name{
query_pairs.append_pair("Name",name);
}
if let Some(hash)=config.Hash{
query_pairs.append_pair("Hash",hash);
}
if let Some(source)=config.Source{
query_pairs.append_pair("Source",source);
}
if let Some(submission_id)=config.SubmissionID{
query_pairs.append_pair("SubmissionID",submission_id.to_string().as_str());
}
}
response_ok(
self.0.get(url).await.map_err(Error::Reqwest)?
).await.map_err(Error::Response)?
.json().await.map_err(Error::Reqwest)
}
pub async fn get_script_from_hash<'a>(&self,config:HashRequest<'a>)->Result<Option<ScriptResponse>,SingleItemError>{
let scripts=self.get_scripts(GetScriptsRequest{
Page:1,
Limit:2,
Hash:Some(config.hash),
Name:None,
Source:None,
SubmissionID:None,
}).await.map_err(SingleItemError::Other)?;
if 1<scripts.len(){
return Err(SingleItemError::DuplicateItems);
}
Ok(scripts.into_iter().next())
}
pub async fn create_script<'a>(&self,config:CreateScriptRequest<'a>)->Result<ScriptIDResponse,Error>{ pub async fn create_script<'a>(&self,config:CreateScriptRequest<'a>)->Result<ScriptIDResponse,Error>{
let url_raw=format!("{}/scripts",self.0.base_url); let url_raw=format!("{}/scripts",self.0.base_url);
let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?; let url=reqwest::Url::parse(url_raw.as_str()).map_err(Error::Parse)?;

@ -60,9 +60,9 @@ pub async fn response_ok(response:reqwest::Response)->Result<reqwest::Response,R
} }
} }
#[derive(Clone,Copy,PartialEq,Eq,serde::Serialize,serde::Deserialize)] #[derive(Clone,Copy,Debug,PartialEq,Eq,serde::Serialize,serde::Deserialize)]
pub struct ScriptID(pub(crate)i64); pub struct ScriptID(pub(crate)i64);
#[derive(Clone,Copy,serde::Serialize,serde::Deserialize)] #[derive(Clone,Copy,Debug,serde::Serialize,serde::Deserialize)]
pub struct ScriptPolicyID(pub(crate)i64); pub struct ScriptPolicyID(pub(crate)i64);
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
@ -70,7 +70,7 @@ pub struct GetScriptRequest{
pub ScriptID:ScriptID, pub ScriptID:ScriptID,
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(serde::Serialize)] #[derive(Clone,Debug,serde::Serialize)]
pub struct GetScriptsRequest<'a>{ pub struct GetScriptsRequest<'a>{
pub Page:u32, pub Page:u32,
pub Limit:u32, pub Limit:u32,
@ -83,11 +83,12 @@ pub struct GetScriptsRequest<'a>{
#[serde(skip_serializing_if="Option::is_none")] #[serde(skip_serializing_if="Option::is_none")]
pub SubmissionID:Option<i64>, pub SubmissionID:Option<i64>,
} }
#[derive(Clone,Copy,Debug)]
pub struct HashRequest<'a>{ pub struct HashRequest<'a>{
pub hash:&'a str, pub hash:&'a str,
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(serde::Deserialize)] #[derive(Clone,Debug,serde::Deserialize)]
pub struct ScriptResponse{ pub struct ScriptResponse{
pub ID:ScriptID, pub ID:ScriptID,
pub Name:String, pub Name:String,
@ -96,7 +97,7 @@ pub struct ScriptResponse{
pub SubmissionID:i64, pub SubmissionID:i64,
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(serde::Serialize)] #[derive(Clone,Debug,serde::Serialize)]
pub struct CreateScriptRequest<'a>{ pub struct CreateScriptRequest<'a>{
pub Name:&'a str, pub Name:&'a str,
pub Source:&'a str, pub Source:&'a str,
@ -104,12 +105,12 @@ pub struct CreateScriptRequest<'a>{
pub SubmissionID:Option<i64>, pub SubmissionID:Option<i64>,
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(serde::Deserialize)] #[derive(Clone,Debug,serde::Deserialize)]
pub struct ScriptIDResponse{ pub struct ScriptIDResponse{
pub ID:ScriptID, pub ID:ScriptID,
} }
#[derive(PartialEq,Eq,serde_repr::Serialize_repr,serde_repr::Deserialize_repr)] #[derive(Clone,Copy,Debug,PartialEq,Eq,serde_repr::Serialize_repr,serde_repr::Deserialize_repr)]
#[repr(i32)] #[repr(i32)]
pub enum Policy{ pub enum Policy{
None=0, // not yet reviewed None=0, // not yet reviewed
@ -120,7 +121,7 @@ pub enum Policy{
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(serde::Serialize)] #[derive(Clone,Debug,serde::Serialize)]
pub struct GetScriptPoliciesRequest<'a>{ pub struct GetScriptPoliciesRequest<'a>{
pub Page:u32, pub Page:u32,
pub Limit:u32, pub Limit:u32,
@ -132,7 +133,7 @@ pub struct GetScriptPoliciesRequest<'a>{
pub Policy:Option<Policy>, pub Policy:Option<Policy>,
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(serde::Deserialize)] #[derive(Clone,Debug,serde::Deserialize)]
pub struct ScriptPolicyResponse{ pub struct ScriptPolicyResponse{
pub ID:ScriptPolicyID, pub ID:ScriptPolicyID,
pub FromScriptHash:String, pub FromScriptHash:String,
@ -140,20 +141,20 @@ pub struct ScriptPolicyResponse{
pub Policy:Policy pub Policy:Policy
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(serde::Serialize)] #[derive(Clone,Debug,serde::Serialize)]
pub struct CreateScriptPolicyRequest{ pub struct CreateScriptPolicyRequest{
pub FromScriptID:ScriptID, pub FromScriptID:ScriptID,
pub ToScriptID:ScriptID, pub ToScriptID:ScriptID,
pub Policy:Policy, pub Policy:Policy,
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(serde::Deserialize)] #[derive(Clone,Debug,serde::Deserialize)]
pub struct ScriptPolicyIDResponse{ pub struct ScriptPolicyIDResponse{
pub ID:ScriptPolicyID, pub ID:ScriptPolicyID,
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(serde::Serialize)] #[derive(Clone,Debug,serde::Serialize)]
pub struct UpdateScriptPolicyRequest{ pub struct UpdateScriptPolicyRequest{
pub ID:ScriptPolicyID, pub ID:ScriptPolicyID,
#[serde(skip_serializing_if="Option::is_none")] #[serde(skip_serializing_if="Option::is_none")]
@ -165,6 +166,7 @@ pub struct UpdateScriptPolicyRequest{
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(Clone,Debug)]
pub struct UpdateSubmissionModelRequest{ pub struct UpdateSubmissionModelRequest{
pub SubmissionID:i64, pub SubmissionID:i64,
pub ModelID:u64, pub ModelID:u64,
@ -172,15 +174,18 @@ pub struct UpdateSubmissionModelRequest{
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(Clone,Debug)]
pub struct ActionSubmissionUploadedRequest{ pub struct ActionSubmissionUploadedRequest{
pub SubmissionID:i64, pub SubmissionID:i64,
pub TargetAssetID:Option<u64>, pub TargetAssetID:Option<u64>,
} }
#[allow(nonstandard_style)] #[allow(nonstandard_style)]
#[derive(Clone,Debug)]
pub struct ActionSubmissionAcceptedRequest{ pub struct ActionSubmissionAcceptedRequest{
pub SubmissionID:i64, pub SubmissionID:i64,
pub StatusMessage:String, pub StatusMessage:String,
} }
#[derive(Clone,Copy,Debug)]
pub struct SubmissionID(pub i64); pub struct SubmissionID(pub i64);

@ -39,8 +39,7 @@ async fn main()->Result<(),StartupError>{
let cookie_context=rbx_asset::cookie::CookieContext::new(rbx_asset::cookie::Cookie::new(cookie)); let cookie_context=rbx_asset::cookie::CookieContext::new(rbx_asset::cookie::Cookie::new(cookie));
// maps-service api // maps-service api
let mut api_host_internal=std::env::var("API_HOST_INTERNAL").expect("API_HOST_INTERNAL env required"); let api_host_internal=std::env::var("API_HOST_INTERNAL").expect("API_HOST_INTERNAL env required");
api_host_internal+="v1/";
let api=submissions_api::internal::Context::new(api_host_internal).map_err(StartupError::API)?; let api=submissions_api::internal::Context::new(api_host_internal).map_err(StartupError::API)?;
// nats // nats

@ -7,7 +7,7 @@ pub enum PublishError{
Json(serde_json::Error), Json(serde_json::Error),
Create(rbx_asset::cookie::CreateError), Create(rbx_asset::cookie::CreateError),
SystemTime(std::time::SystemTimeError), SystemTime(std::time::SystemTimeError),
ApiActionSubmissionPublish(submissions_api::Error), ApiActionSubmissionUploaded(submissions_api::Error),
} }
impl std::fmt::Display for PublishError{ impl std::fmt::Display for PublishError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
@ -53,7 +53,7 @@ impl Publisher{
self.api.action_submission_uploaded(submissions_api::types::ActionSubmissionUploadedRequest{ self.api.action_submission_uploaded(submissions_api::types::ActionSubmissionUploadedRequest{
SubmissionID:publish_info.SubmissionID, SubmissionID:publish_info.SubmissionID,
TargetAssetID:Some(upload_response.AssetId), TargetAssetID:Some(upload_response.AssetId),
}).await.map_err(PublishError::ApiActionSubmissionPublish)?; }).await.map_err(PublishError::ApiActionSubmissionUploaded)?;
Ok(()) Ok(())
} }

@ -21,23 +21,33 @@ fn source_has_illegal_keywords(source:&str)->bool{
source.find("getfenv").is_some()||source.find("require").is_some() source.find("getfenv").is_some()||source.find("require").is_some()
} }
fn hash_source(source:&str)->String{
let mut hasher=siphasher::sip::SipHasher::new();
std::hash::Hasher::write(&mut hasher,source.as_bytes());
let hash=std::hash::Hasher::finish(&hasher);
format!("{:016x}",hash)
}
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug)] #[derive(Debug)]
pub enum ValidateError{ pub enum ValidateError{
Flagged, ScriptFlaggedIllegalKeyword(String),
Blocked, ScriptBlocked(Option<submissions_api::types::ScriptID>),
NotAllowed, ScriptNotYetReviewed(Option<submissions_api::types::ScriptID>),
Get(rbx_asset::cookie::GetError), ModelFileDownload(rbx_asset::cookie::GetError),
ReadDom(ReadDomError), ModelFileDecode(ReadDomError),
ApiGetScriptPolicy(submissions_api::types::SingleItemError), ApiGetScriptPolicyFromHash(submissions_api::types::SingleItemError),
ApiGetScript(submissions_api::Error), ApiGetScript(submissions_api::Error),
ApiCreateScript(submissions_api::Error), ApiCreateScript(submissions_api::Error),
ApiCreateScriptPolicy(submissions_api::Error), ApiCreateScriptPolicy(submissions_api::Error),
ApiGetScriptFromHash(submissions_api::types::SingleItemError),
ApiUpdateSubmissionModel(submissions_api::Error), ApiUpdateSubmissionModel(submissions_api::Error),
ApiActionSubmissionValidate(submissions_api::Error), ApiActionSubmissionValidate(submissions_api::Error),
WriteDom(rbx_binary::EncodeError), ModelFileRootMustHaveOneChild,
Upload(rbx_asset::cookie::UploadError), ModelFileChildRefIsNil,
Create(rbx_asset::cookie::CreateError), ModelFileEncode(rbx_binary::EncodeError),
AssetUpload(rbx_asset::cookie::UploadError),
AssetCreate(rbx_asset::cookie::CreateError),
} }
impl std::fmt::Display for ValidateError{ impl std::fmt::Display for ValidateError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
@ -89,10 +99,10 @@ impl Validator{
let data=self.roblox_cookie.get_asset(rbx_asset::cookie::GetAssetRequest{ let data=self.roblox_cookie.get_asset(rbx_asset::cookie::GetAssetRequest{
asset_id:validate_info.ModelID, asset_id:validate_info.ModelID,
version:Some(validate_info.ModelVersion), version:Some(validate_info.ModelVersion),
}).await.map_err(ValidateError::Get)?; }).await.map_err(ValidateError::ModelFileDownload)?;
// decode dom (slow!) // decode dom (slow!)
let mut dom=read_dom(&mut std::io::Cursor::new(data)).map_err(ValidateError::ReadDom)?; let mut dom=read_dom(&mut std::io::Cursor::new(data)).map_err(ValidateError::ModelFileDecode)?;
/* VALIDATE MAP */ /* VALIDATE MAP */
@ -105,12 +115,14 @@ impl Validator{
// check the source for illegal keywords // check the source for illegal keywords
if source_has_illegal_keywords(source){ if source_has_illegal_keywords(source){
// immediately abort // immediately abort
return Err(ValidateError::Flagged); // grab path to offending script
let path=get_partial_path(&dom,script);
return Err(ValidateError::ScriptFlaggedIllegalKeyword(path));
} }
// associate a name and policy with the source code // associate a name and policy with the source code
// policy will be fetched from the database to replace the default policy // policy will be fetched from the database to replace the default policy
script_map.insert(source.clone(),NamePolicy{ script_map.insert(source.clone(),NamePolicy{
name:script.name.clone(), name:get_partial_path(&dom,script),
policy:Policy::None, policy:Policy::None,
}); });
} }
@ -121,14 +133,12 @@ impl Validator{
futures::stream::iter(script_map.iter_mut().map(Ok)) futures::stream::iter(script_map.iter_mut().map(Ok))
.try_for_each_concurrent(Some(SCRIPT_CONCURRENCY),|(source,NamePolicy{policy,name})|async{ .try_for_each_concurrent(Some(SCRIPT_CONCURRENCY),|(source,NamePolicy{policy,name})|async{
// get the hash // get the hash
let mut hasher=siphasher::sip::SipHasher::new(); let hash=hash_source(source.as_str());
std::hash::Hasher::write(&mut hasher,source.as_bytes());
let hash=std::hash::Hasher::finish(&hasher);
// fetch the script policy // fetch the script policy
let script_policy=self.api.get_script_policy_from_hash(submissions_api::types::HashRequest{ let script_policy=self.api.get_script_policy_from_hash(submissions_api::types::HashRequest{
hash:format!("{:016x}",hash).as_str(), hash:hash.as_str(),
}).await.map_err(ValidateError::ApiGetScriptPolicy)?; }).await.map_err(ValidateError::ApiGetScriptPolicyFromHash)?;
// write the policy to the script_map, fetching the replacement code if necessary // write the policy to the script_map, fetching the replacement code if necessary
if let Some(script_policy)=script_policy{ if let Some(script_policy)=script_policy{
@ -170,10 +180,22 @@ impl Validator{
if let Some(script)=dom.get_by_ref_mut(script_ref){ 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"){
match script_map.get(source.as_str()).map(|p|&p.policy){ match script_map.get(source.as_str()).map(|p|&p.policy){
Some(Policy::Blocked)=>return Err(ValidateError::Blocked), Some(Policy::Blocked)=>{
let hash=hash_source(source.as_str());
let script=self.api.get_script_from_hash(submissions_api::types::HashRequest{
hash:hash.as_str(),
}).await.map_err(ValidateError::ApiGetScriptFromHash)?;
return Err(ValidateError::ScriptBlocked(script.map(|s|s.ID)));
},
None None
|Some(Policy::None) |Some(Policy::None)
=>return Err(ValidateError::NotAllowed), =>{
let hash=hash_source(source.as_str());
let script=self.api.get_script_from_hash(submissions_api::types::HashRequest{
hash:hash.as_str(),
}).await.map_err(ValidateError::ApiGetScriptFromHash)?;
return Err(ValidateError::ScriptNotYetReviewed(script.map(|s|s.ID)));
},
Some(Policy::Allowed)=>(), Some(Policy::Allowed)=>(),
Some(Policy::Delete)=>{ Some(Policy::Delete)=>{
modified=true; modified=true;
@ -195,7 +217,10 @@ impl Validator{
if modified{ if modified{
// serialize model (slow!) // serialize model (slow!)
let mut data=Vec::new(); let mut data=Vec::new();
rbx_binary::to_writer(&mut data,&dom,dom.root().children()).map_err(ValidateError::WriteDom)?; let &[map_ref]=dom.root().children()else{
return Err(ValidateError::ModelFileRootMustHaveOneChild);
};
rbx_binary::to_writer(&mut data,&dom,&[map_ref]).map_err(ValidateError::ModelFileEncode)?;
// upload a model lol // upload a model lol
let model_id=if let Some(model_id)=validate_info.ValidatedModelID{ let model_id=if let Some(model_id)=validate_info.ValidatedModelID{
@ -207,18 +232,22 @@ impl Validator{
ispublic:None, ispublic:None,
allowComments:None, allowComments:None,
groupId:None, groupId:None,
},data).await.map_err(ValidateError::Upload)?; },data).await.map_err(ValidateError::AssetUpload)?;
response.AssetId response.AssetId
}else{ }else{
// grab the map instance from the map re
let Some(map_instance)=dom.get_by_ref(map_ref)else{
return Err(ValidateError::ModelFileChildRefIsNil);
};
// create new model // create new model
let response=self.roblox_cookie.create(rbx_asset::cookie::CreateRequest{ let response=self.roblox_cookie.create(rbx_asset::cookie::CreateRequest{
name:dom.root().name.clone(), name:map_instance.name.clone(),
description:"".to_owned(), description:"".to_owned(),
ispublic:true, ispublic:true,
allowComments:true, allowComments:true,
groupId:None, groupId:None,
},data).await.map_err(ValidateError::Create)?; },data).await.map_err(ValidateError::AssetCreate)?;
response.AssetId response.AssetId
}; };
@ -291,6 +320,25 @@ fn recursive_collect_superclass(objects:&mut std::vec::Vec<rbx_dom_weak::types::
} }
} }
fn get_partial_path(dom:&rbx_dom_weak::WeakDom,instance:&rbx_dom_weak::Instance)->String{
struct ParentIter<'a>{
dom:&'a rbx_dom_weak::WeakDom,
instance:Option<&'a rbx_dom_weak::Instance>,
}
impl<'a> Iterator for ParentIter<'a>{
type Item=&'a rbx_dom_weak::Instance;
fn next(&mut self)->Option<Self::Item>{
let parent=self.instance.map(|i|i.parent()).and_then(|p|self.dom.get_by_ref(p));
core::mem::replace(&mut self.instance,parent)
}
}
let mut tragic:Vec<_>=ParentIter{dom,instance:Some(instance)}.map(|i|i.name.as_str()).collect();
tragic.pop();
tragic.reverse();
tragic.join(".")
}
fn get_script_refs(dom:&rbx_dom_weak::WeakDom)->Vec<rbx_dom_weak::types::Ref>{ fn get_script_refs(dom:&rbx_dom_weak::WeakDom)->Vec<rbx_dom_weak::types::Ref>{
let mut scripts=std::vec::Vec::new(); let mut scripts=std::vec::Vec::new();
recursive_collect_superclass(&mut scripts,dom,dom.root(),"LuaSourceContainer"); recursive_collect_superclass(&mut scripts,dom,dom.root(),"LuaSourceContainer");