package service import ( "context" "encoding/json" "errors" "fmt" "time" "git.itzana.me/strafesnet/go-grpc/maps" "git.itzana.me/strafesnet/maps-service/pkg/api" "git.itzana.me/strafesnet/maps-service/pkg/datastore" "git.itzana.me/strafesnet/maps-service/pkg/model" ) var( CreationPhaseSubmissionsLimit = 20 CreationPhaseSubmissionStatuses = []model.SubmissionStatus{ model.SubmissionStatusChangesRequested, model.SubmissionStatusSubmitted, model.SubmissionStatusUnderConstruction, } // prevent two mapfixes with same asset id ActiveSubmissionStatuses = []model.SubmissionStatus{ model.SubmissionStatusUploading, model.SubmissionStatusValidated, model.SubmissionStatusValidating, model.SubmissionStatusAccepted, model.SubmissionStatusChangesRequested, model.SubmissionStatusSubmitted, model.SubmissionStatusUnderConstruction, } // limit mapfixes in the pipeline to one per target map ActiveAcceptedSubmissionStatuses = []model.SubmissionStatus{ model.SubmissionStatusUploading, model.SubmissionStatusValidated, model.SubmissionStatusValidating, model.SubmissionStatusAccepted, } ) var ( ErrCreationPhaseSubmissionsLimit = errors.New("Active submissions limited to 20") ErrActiveSubmissionSameAssetID = errors.New("There is an active submission with the same AssetID") ErrUploadedAssetIDAlreadyExists = errors.New("The submission UploadedAssetID is already set") ErrReleaseInvalidStatus = errors.New("Only submissions with Uploaded status can be released") ErrReleaseNoUploadedAssetID = errors.New("Only submissions with a UploadedAssetID can be released") ErrAcceptOwnSubmission = fmt.Errorf("%w: You cannot accept your own submission as the submitter", ErrPermissionDenied) ) // POST /submissions func (svc *Service) CreateSubmission(ctx context.Context, request *api.SubmissionCreate) (*api.ID, error) { userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) if !ok { return nil, ErrUserInfo } userId, err := userInfo.GetUserID() if err != nil { return nil, err } // Check if user's submissions in the creation phase exceeds the limit { filter := datastore.Optional() filter.Add("submitter", int64(userId)) filter.Add("status_id", CreationPhaseSubmissionStatuses) creation_submissions, err := svc.DB.Submissions().List(ctx, filter, model.Page{ Number: 1, Size: int32(CreationPhaseSubmissionsLimit), },datastore.ListSortDisabled) if err != nil { return nil, err } if CreationPhaseSubmissionsLimit <= len(creation_submissions) { return nil, ErrCreationPhaseSubmissionsLimit } } // Check if an active submission with the same asset id exists { filter := datastore.Optional() filter.Add("asset_id", request.AssetID) filter.Add("asset_version", request.AssetVersion) filter.Add("status_id", ActiveSubmissionStatuses) active_submissions, err := svc.DB.Submissions().List(ctx, filter, model.Page{ Number: 1, Size: 1, },datastore.ListSortDisabled) if err != nil { return nil, err } if len(active_submissions) != 0{ return nil, ErrActiveSubmissionSameAssetID } } submission, err := svc.DB.Submissions().Create(ctx, model.Submission{ ID: 0, DisplayName: request.DisplayName, Creator: request.Creator, GameID: request.GameID, Submitter: int64(userId), AssetID: request.AssetID, AssetVersion: request.AssetVersion, Completed: false, StatusID: model.SubmissionStatusUnderConstruction, }) if err != nil { return nil, err } return &api.ID{ ID: submission.ID, }, nil } // GetSubmission implements getSubmission operation. // // Retrieve map with ID. // // GET /submissions/{SubmissionID} func (svc *Service) GetSubmission(ctx context.Context, params api.GetSubmissionParams) (*api.Submission, error) { submission, err := svc.DB.Submissions().Get(ctx, params.SubmissionID) if err != nil { return nil, err } return &api.Submission{ ID: submission.ID, DisplayName: submission.DisplayName, Creator: submission.Creator, GameID: submission.GameID, CreatedAt: submission.CreatedAt.Unix(), UpdatedAt: submission.UpdatedAt.Unix(), Submitter: int64(submission.Submitter), AssetID: int64(submission.AssetID), AssetVersion: int64(submission.AssetVersion), Completed: submission.Completed, UploadedAssetID: api.NewOptInt64(int64(submission.UploadedAssetID)), StatusID: int32(submission.StatusID), StatusMessage: submission.StatusMessage, }, nil } // ListSubmissions implements listSubmissions operation. // // Get list of submissions. // // GET /submissions func (svc *Service) ListSubmissions(ctx context.Context, params api.ListSubmissionsParams) ([]api.Submission, error) { filter := datastore.Optional() if params.DisplayName.IsSet(){ filter.Add("display_name", params.DisplayName.Value) } if params.Creator.IsSet(){ filter.Add("creator", params.Creator.Value) } if params.GameID.IsSet(){ filter.Add("game_id", params.GameID.Value) } sort := datastore.ListSort(params.Sort.Or(int32(datastore.ListSortDisabled))) items, err := svc.DB.Submissions().List(ctx, filter, model.Page{ Number: params.Page, Size: params.Limit, },sort) if err != nil { return nil, err } var resp []api.Submission for _, item := range items { resp = append(resp, api.Submission{ ID: item.ID, DisplayName: item.DisplayName, Creator: item.Creator, GameID: item.GameID, CreatedAt: item.CreatedAt.Unix(), UpdatedAt: item.UpdatedAt.Unix(), Submitter: int64(item.Submitter), AssetID: int64(item.AssetID), AssetVersion: int64(item.AssetVersion), Completed: item.Completed, UploadedAssetID: api.NewOptInt64(int64(item.UploadedAssetID)), StatusID: int32(item.StatusID), }) } return resp, nil } // PatchSubmissionCompleted implements patchSubmissionCompleted operation. // // Retrieve map with ID. // // POST /submissions/{SubmissionID}/completed func (svc *Service) SetSubmissionCompleted(ctx context.Context, params api.SetSubmissionCompletedParams) error { userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) if !ok { return ErrUserInfo } has_role, err := userInfo.HasRoleMaptest() if err != nil { return err } // check if caller has MaptestGame role (request must originate from a maptest roblox game) if !has_role { return ErrPermissionDeniedNeedRoleMaptest } pmap := datastore.Optional() pmap.Add("completed", true) return svc.DB.Submissions().Update(ctx, params.SubmissionID, pmap) } // UpdateSubmissionModel implements patchSubmissionModel operation. // // Update model following role restrictions. // // POST /submissions/{SubmissionID}/model func (svc *Service) UpdateSubmissionModel(ctx context.Context, params api.UpdateSubmissionModelParams) 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 } has_role, err := userInfo.IsSubmitter(uint64(submission.Submitter)) if err != nil { return err } // check if caller is the submitter if !has_role { return ErrPermissionDeniedNotSubmitter } // check if Status is ChangesRequested|Submitted|UnderConstruction pmap := datastore.Optional() pmap.AddNotNil("asset_id", params.ModelID) pmap.AddNotNil("asset_version", params.VersionID) //always reset completed when model changes pmap.Add("completed", false) return svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusChangesRequested, model.SubmissionStatusSubmitted, model.SubmissionStatusUnderConstruction}, pmap) } // ActionSubmissionReject invokes actionSubmissionReject operation. // // Role Reviewer changes status from Submitted -> Rejected. // // POST /submissions/{SubmissionID}/status/reject func (svc *Service) ActionSubmissionReject(ctx context.Context, params api.ActionSubmissionRejectParams) error { userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) if !ok { return ErrUserInfo } has_role, err := userInfo.HasRoleSubmissionReview() if err != nil { return err } // check if caller has required role if !has_role { return ErrPermissionDeniedNeedRoleMapReview } // transaction smap := datastore.Optional() smap.Add("status_id", model.SubmissionStatusRejected) return svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusSubmitted}, smap) } // ActionSubmissionRequestChanges invokes actionSubmissionRequestChanges operation. // // Role Reviewer changes status from Validated|Accepted|Submitted -> ChangesRequested. // // POST /submissions/{SubmissionID}/status/request-changes func (svc *Service) ActionSubmissionRequestChanges(ctx context.Context, params api.ActionSubmissionRequestChangesParams) error { userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) if !ok { return ErrUserInfo } has_role, err := userInfo.HasRoleSubmissionReview() if err != nil { return err } // check if caller has required role if !has_role { return ErrPermissionDeniedNeedRoleMapReview } // transaction smap := datastore.Optional() smap.Add("status_id", model.SubmissionStatusChangesRequested) return svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusValidated, model.SubmissionStatusAccepted, model.SubmissionStatusSubmitted}, smap) } // ActionSubmissionRevoke invokes actionSubmissionRevoke operation. // // Role Submitter changes status from Submitted|ChangesRequested -> UnderConstruction. // // POST /submissions/{SubmissionID}/status/revoke func (svc *Service) ActionSubmissionRevoke(ctx context.Context, params api.ActionSubmissionRevokeParams) 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 } has_role, err := userInfo.IsSubmitter(uint64(submission.Submitter)) if err != nil { return err } // check if caller is the submitter if !has_role { return ErrPermissionDeniedNotSubmitter } // transaction smap := datastore.Optional() smap.Add("status_id", model.SubmissionStatusUnderConstruction) return svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusSubmitted, model.SubmissionStatusChangesRequested}, smap) } // ActionSubmissionSubmit invokes actionSubmissionSubmit operation. // // Role Submitter changes status from UnderConstruction|ChangesRequested -> Submitted. // // POST /submissions/{SubmissionID}/status/submit func (svc *Service) ActionSubmissionSubmit(ctx context.Context, params api.ActionSubmissionSubmitParams) 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 } has_role, err := userInfo.IsSubmitter(uint64(submission.Submitter)) if err != nil { return err } // check if caller is the submitter if !has_role { return ErrPermissionDeniedNotSubmitter } // transaction smap := datastore.Optional() smap.Add("status_id", model.SubmissionStatusSubmitted) return svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusUnderConstruction, model.SubmissionStatusChangesRequested}, smap) } // ActionSubmissionTriggerUpload invokes actionSubmissionTriggerUpload operation. // // Role Admin changes status from Validated -> Uploading. // // POST /submissions/{SubmissionID}/status/trigger-upload func (svc *Service) ActionSubmissionTriggerUpload(ctx context.Context, params api.ActionSubmissionTriggerUploadParams) error { userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) if !ok { return ErrUserInfo } has_role, err := userInfo.HasRoleSubmissionUpload() if err != nil { return err } // check if caller has required role if !has_role { return ErrPermissionDeniedNeedRoleMapUpload } // transaction smap := datastore.Optional() smap.Add("status_id", model.SubmissionStatusUploading) submission, err := svc.DB.Submissions().IfStatusThenUpdateAndGet(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusValidated}, smap) if err != nil { return err } // sentinel value because we are not using rust if submission.UploadedAssetID == 0 { // this is a new map upload_new_request := model.UploadSubmissionRequest{ SubmissionID: submission.ID, ModelID: submission.ValidatedAssetID, ModelVersion: submission.ValidatedAssetVersion, // upload as displayname, whatever ModelName: submission.DisplayName, } j, err := json.Marshal(upload_new_request) if err != nil { return err } svc.Nats.Publish("maptest.submissions.upload", []byte(j)) } else { // refuse to operate return ErrUploadedAssetIDAlreadyExists } return nil } // ActionSubmissionValidate invokes actionSubmissionValidate operation. // // Role SubmissionRelease changes status from Uploading -> Validated. // // POST /submissions/{SubmissionID}/status/reset-uploading func (svc *Service) ActionSubmissionValidated(ctx context.Context, params api.ActionSubmissionValidatedParams) error { userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) if !ok { return ErrUserInfo } has_role, err := userInfo.HasRoleSubmissionUpload() if err != nil { return err } // check if caller has required role if !has_role { return ErrPermissionDeniedNeedRoleMapUpload } // check when submission was updated submission, err := svc.DB.Submissions().Get(ctx, params.SubmissionID) if err != nil { return err } if time.Now().Before(submission.UpdatedAt.Add(time.Second*10)) { // the last time the submission was updated must be longer than 10 seconds ago return ErrDelayReset } // transaction smap := datastore.Optional() smap.Add("status_id", model.SubmissionStatusValidated) return svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusUploading}, smap) } // ActionSubmissionTriggerValidate invokes actionSubmissionTriggerValidate operation. // // Role Reviewer triggers validation and changes status from Submitted -> Validating. // // POST /submissions/{SubmissionID}/status/trigger-validate func (svc *Service) ActionSubmissionTriggerValidate(ctx context.Context, params api.ActionSubmissionTriggerValidateParams) error { userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) if !ok { return ErrUserInfo } has_role, err := userInfo.HasRoleSubmissionReview() if err != nil { return err } // check if caller has required role if !has_role { return ErrPermissionDeniedNeedRoleMapReview } // 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 } has_role, err = userInfo.IsSubmitter(uint64(submission.Submitter)) if err != nil { return err } // check if caller is NOT the submitter if has_role { return ErrAcceptOwnSubmission } // transaction smap := datastore.Optional() smap.Add("status_id", model.SubmissionStatusValidating) submission, err = svc.DB.Submissions().IfStatusThenUpdateAndGet(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusSubmitted}, smap) if err != nil { return err } validate_request := model.ValidateSubmissionRequest{ SubmissionID: submission.ID, ModelID: submission.AssetID, ModelVersion: submission.AssetVersion, 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) if err != nil { return err } svc.Nats.Publish("maptest.submissions.validate", []byte(j)) return nil } // ActionSubmissionRetryValidate invokes actionSubmissionRetryValidate operation. // // Role Reviewer re-runs validation and changes status from Accepted -> Validating. // // POST /submissions/{SubmissionID}/status/retry-validate func (svc *Service) ActionSubmissionRetryValidate(ctx context.Context, params api.ActionSubmissionRetryValidateParams) error { userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) if !ok { return ErrUserInfo } has_role, err := userInfo.HasRoleSubmissionReview() if err != nil { return err } // check if caller has required role if !has_role { return ErrPermissionDeniedNeedRoleMapReview } // transaction smap := datastore.Optional() smap.Add("status_id", model.SubmissionStatusValidating) submission, err := svc.DB.Submissions().IfStatusThenUpdateAndGet(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusAccepted}, smap) if err != nil { return err } validate_request := model.ValidateSubmissionRequest{ SubmissionID: submission.ID, ModelID: submission.AssetID, ModelVersion: submission.AssetVersion, 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) if err != nil { return err } svc.Nats.Publish("maptest.submissions.validate", []byte(j)) return nil } // ActionSubmissionAccepted implements actionSubmissionAccepted operation. // // Role SubmissionReview changes status from Validating -> Accepted. // // POST /submissions/{SubmissionID}/status/reset-validating func (svc *Service) ActionSubmissionAccepted(ctx context.Context, params api.ActionSubmissionAcceptedParams) error { userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) if !ok { return ErrUserInfo } has_role, err := userInfo.HasRoleSubmissionReview() if err != nil { return err } // check if caller has required role if !has_role { return ErrPermissionDeniedNeedRoleMapReview } // check when submission was updated submission, err := svc.DB.Submissions().Get(ctx, params.SubmissionID) if err != nil { return err } if time.Now().Before(submission.UpdatedAt.Add(time.Second*10)) { // the last time the submission was updated must be longer than 10 seconds ago return ErrDelayReset } // transaction smap := datastore.Optional() smap.Add("status_id", model.SubmissionStatusAccepted) smap.Add("status_message", "Manually forced reset") return svc.DB.Submissions().IfStatusThenUpdate(ctx, params.SubmissionID, []model.SubmissionStatus{model.SubmissionStatusValidating}, smap) } // ReleaseSubmissions invokes releaseSubmissions operation. // // Release a set of uploaded maps. // // POST /release-submissions func (svc *Service) ReleaseSubmissions(ctx context.Context, request []api.ReleaseInfo) error { userInfo, ok := ctx.Value("UserInfo").(UserInfoHandle) if !ok { return ErrUserInfo } has_role, err := userInfo.HasRoleSubmissionRelease() if err != nil { return err } // check if caller has required role if !has_role { return ErrPermissionDeniedNeedRoleSubmissionRelease } idList := make([]int64, len(request)) for i, releaseInfo := range request { idList[i] = releaseInfo.SubmissionID } // fetch submissions submissions, err := svc.DB.Submissions().GetList(ctx, idList) if err != nil { return err } // check each submission to make sure it is ready to release for _,submission := range submissions{ if submission.StatusID != model.SubmissionStatusUploaded{ return ErrReleaseInvalidStatus } if submission.UploadedAssetID == 0{ return ErrReleaseNoUploadedAssetID } } for i,submission := range submissions{ date := request[i].Date.Unix() // create each map with go-grpc _, err := svc.Client.Create(ctx, &maps.MapRequest{ ID: submission.UploadedAssetID, DisplayName: &submission.DisplayName, Creator: &submission.Creator, GameID: &submission.GameID, Date: &date, }) if err != nil { return err } // update each status to Released smap := datastore.Optional() smap.Add("status_id", model.SubmissionStatusReleased) err = svc.DB.Submissions().IfStatusThenUpdate(ctx, submission.ID, []model.SubmissionStatus{model.SubmissionStatusUploaded}, smap) if err != nil { return err } } return nil }