From 4f31f8c75a9082bbf0dc366abf16c90a1f219d68 Mon Sep 17 00:00:00 2001 From: 9382 Date: Tue, 3 Jun 2025 22:32:43 +0100 Subject: [PATCH 01/22] Add a favicon (#141) --- web/src/app/favicon.ico | Bin 0 -> 15406 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 web/src/app/favicon.ico diff --git a/web/src/app/favicon.ico b/web/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6f72cd31c41a1d6c19aea46c3a56ce1042087ce0 GIT binary patch literal 15406 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 WUR<%`U;O`A8ZY|)fBz3t;C}+CvBrY{ literal 0 HcmV?d00001 From 740e3c8932800fa1687633f3c6ee6428ee4d63da Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Tue, 3 Jun 2025 15:58:33 -0600 Subject: [PATCH 02/22] API_HOST changes, thumbnail fix & cache, "list is empty" fix API_HOST was replaced in order for thumbnail/any redirects to work properly, this also assumes the API will be at `{BASE_URL}/api`, assuming the reverse proxy causes issues with the way redirects were initially setup to work. Also no more "Submissions list is empty." while it's loading. --- README.md | 4 +- web/src/app/mapfixes/page.tsx | 6 +-- web/src/app/submissions/page.tsx | 6 +-- .../app/thumbnails/asset/[assetId]/route.tsx | 19 ++++++++++ web/src/app/thumbnails/maps/[mapId]/route.tsx | 9 +++-- web/src/middleware.ts | 37 ++++++++++--------- 6 files changed, 53 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 75dc884..6c82469 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ Prerequisite: golang installed Prerequisite: bun installed -The environment variables `API_HOST` and `AUTH_HOST` will need to be set for the middleware. +The environment variables `BASE_URL` and `AUTH_HOST` will need to be set for the middleware. Example `.env` in web's root: ``` -API_HOST="http://localhost:8082/v1/" +BASE_URL="http://localhost:8082/" AUTH_HOST="http://localhost:8083/" ``` diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx index 98b31e2..4d86e66 100644 --- a/web/src/app/mapfixes/page.tsx +++ b/web/src/app/mapfixes/page.tsx @@ -11,7 +11,7 @@ import "./(styles)/page.scss"; import { ListSortConstants } from "../ts/Sort"; export default function MapfixInfoPage() { - const [mapfixes, setMapfixes] = useState({Total:0,Mapfixes:[]}) + const [mapfixes, setMapfixes] = useState({Total:-1,Mapfixes:[]}) const [currentPage, setCurrentPage] = useState(1); const cardsPerPage = 24; // built to fit on a 1920x1080 monitor @@ -47,7 +47,7 @@ export default function MapfixInfoPage() { }, 50); }, [currentPage]) - if (!mapfixes) { + if (mapfixes.Total < 0) { return
Loading... @@ -55,7 +55,7 @@ export default function MapfixInfoPage() { } - if (mapfixes && mapfixes.Total == 0) { + if (mapfixes.Total == 0) { return
Mapfixes list is empty. diff --git a/web/src/app/submissions/page.tsx b/web/src/app/submissions/page.tsx index 8a7c017..1edfb9f 100644 --- a/web/src/app/submissions/page.tsx +++ b/web/src/app/submissions/page.tsx @@ -9,7 +9,7 @@ import "./(styles)/page.scss"; import { ListSortConstants } from "../ts/Sort"; export default function SubmissionInfoPage() { - const [submissions, setSubmissions] = useState({Total:0,Submissions:[]}) + const [submissions, setSubmissions] = useState({Total:-1,Submissions:[]}) const [currentPage, setCurrentPage] = useState(1); const cardsPerPage = 24; // built to fit on a 1920x1080 monitor @@ -45,7 +45,7 @@ export default function SubmissionInfoPage() { }, 50); }, [currentPage]) - if (!submissions) { + if (submissions.Total < 0) { return
Loading... @@ -53,7 +53,7 @@ export default function SubmissionInfoPage() { } - if (submissions && submissions.Total == 0) { + if (submissions.Total == 0) { return
Submissions list is empty. diff --git a/web/src/app/thumbnails/asset/[assetId]/route.tsx b/web/src/app/thumbnails/asset/[assetId]/route.tsx index b7992f4..e7232d8 100644 --- a/web/src/app/thumbnails/asset/[assetId]/route.tsx +++ b/web/src/app/thumbnails/asset/[assetId]/route.tsx @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; +const cache = new Map(); +const CACHE_TTL = 15 * 60 * 1000; + export async function GET( request: NextRequest, context: { params: Promise<{ assetId: number }> } @@ -27,6 +30,19 @@ 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}` @@ -54,10 +70,13 @@ export async function GET( 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 { diff --git a/web/src/app/thumbnails/maps/[mapId]/route.tsx b/web/src/app/thumbnails/maps/[mapId]/route.tsx index 174feee..a923d21 100644 --- a/web/src/app/thumbnails/maps/[mapId]/route.tsx +++ b/web/src/app/thumbnails/maps/[mapId]/route.tsx @@ -5,9 +5,12 @@ export async function GET( context: { params: Promise<{ mapId: string }> } ): Promise { // 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 - return NextResponse.redirect(`${baseUrl}/thumbnails/asset/${mapId}`); + const protocol = request.headers.get("x-forwarded-proto") || "https"; + const host = request.headers.get("host"); + const origin = `${protocol}://${host}`; + + return NextResponse.redirect(`${origin}/thumbnails/asset/${mapId}`); } \ No newline at end of file diff --git a/web/src/middleware.ts b/web/src/middleware.ts index 0a25a76..4bdf6cc 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -1,29 +1,32 @@ 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.BASE_URL) { + throw new Error('env variable "BASE_URL" is not set') + } + + const baseUrl = process.env.BASE_URL.replace(/\/$/, ""); + const apiUrl = new URL(baseUrl + pathname + 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) - } + + const authHost = process.env.AUTH_HOST.replace(/\/$/, ""); + const path = pathname.replace(/^\/auth/, ""); + const redirectUrl = new URL(authHost + path + search); - return NextResponse.next() + return NextResponse.redirect(redirectUrl, 302); + } + + return NextResponse.next() } \ No newline at end of file From 954dbaeac65b782f6d5b8eee68412e7761324a69 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 3 Jun 2025 16:27:42 -0700 Subject: [PATCH 03/22] env var name change requires deployment configuration change --- README.md | 4 ++-- web/src/middleware.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6c82469..7930a81 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ Prerequisite: golang installed Prerequisite: bun installed -The environment variables `BASE_URL` and `AUTH_HOST` will need to be set for the middleware. +The environment variables `API_HOST` and `AUTH_HOST` will need to be set for the middleware. Example `.env` in web's root: ``` -BASE_URL="http://localhost:8082/" +API_HOST="http://localhost:8082/" AUTH_HOST="http://localhost:8083/" ``` diff --git a/web/src/middleware.ts b/web/src/middleware.ts index 4bdf6cc..61da104 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -8,11 +8,11 @@ export function middleware(request: NextRequest) { const { pathname, search } = request.nextUrl if (pathname.startsWith("/api")) { - if (!process.env.BASE_URL) { - throw new Error('env variable "BASE_URL" is not set') + if (!process.env.API_HOST) { + throw new Error('env variable "API_HOST" is not set') } - - const baseUrl = process.env.BASE_URL.replace(/\/$/, ""); + + const baseUrl = process.env.API_HOST.replace(/\/$/, ""); const apiUrl = new URL(baseUrl + pathname + search); return NextResponse.rewrite(apiUrl, { request }); @@ -20,13 +20,13 @@ export function middleware(request: NextRequest) { 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() -} \ No newline at end of file +} From 8ea5ee2d410ecbddaee3b528faffb246fdcfde48 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 3 Jun 2025 16:29:29 -0700 Subject: [PATCH 04/22] use null instead of sentinel value --- web/src/app/mapfixes/page.tsx | 44 ++++++++++++++++---------------- web/src/app/submissions/page.tsx | 44 ++++++++++++++++---------------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx index 4d86e66..e4674b6 100644 --- a/web/src/app/mapfixes/page.tsx +++ b/web/src/app/mapfixes/page.tsx @@ -11,10 +11,31 @@ import "./(styles)/page.scss"; import { ListSortConstants } from "../ts/Sort"; export default function MapfixInfoPage() { - const [mapfixes, setMapfixes] = useState({Total:-1,Mapfixes:[]}) + const [mapfixes, setMapfixes] = useState(null) const [currentPage, setCurrentPage] = useState(1); const cardsPerPage = 24; // built to fit on a 1920x1080 monitor + useEffect(() => { + async function fetchMapfixes() { + const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`) + if (res.ok) { + setMapfixes(await res.json()) + } + } + + setTimeout(() => { + fetchMapfixes() + }, 50); + }, [currentPage]) + + if (!mapfixes) { + return +
+ Loading... +
+
+ } + const totalPages = Math.ceil(mapfixes.Total / cardsPerPage); const currentCards = mapfixes.Mapfixes.slice( @@ -34,27 +55,6 @@ export default function MapfixInfoPage() { } }; - useEffect(() => { - async function fetchMapfixes() { - const res = await fetch(`/api/mapfixes?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`) - if (res.ok) { - setMapfixes(await res.json()) - } - } - - setTimeout(() => { - fetchMapfixes() - }, 50); - }, [currentPage]) - - if (mapfixes.Total < 0) { - return -
- Loading... -
-
- } - if (mapfixes.Total == 0) { return
diff --git a/web/src/app/submissions/page.tsx b/web/src/app/submissions/page.tsx index 1edfb9f..bd67b33 100644 --- a/web/src/app/submissions/page.tsx +++ b/web/src/app/submissions/page.tsx @@ -9,10 +9,31 @@ import "./(styles)/page.scss"; import { ListSortConstants } from "../ts/Sort"; export default function SubmissionInfoPage() { - const [submissions, setSubmissions] = useState({Total:-1,Submissions:[]}) + const [submissions, setSubmissions] = useState(null) const [currentPage, setCurrentPage] = useState(1); const cardsPerPage = 24; // built to fit on a 1920x1080 monitor + useEffect(() => { + async function fetchSubmissions() { + const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`) + if (res.ok) { + setSubmissions(await res.json()) + } + } + + setTimeout(() => { + fetchSubmissions() + }, 50); + }, [currentPage]) + + if (!submissions) { + return +
+ Loading... +
+
+ } + const totalPages = Math.ceil(submissions.Total / cardsPerPage); const currentCards = submissions.Submissions.slice( @@ -32,27 +53,6 @@ export default function SubmissionInfoPage() { } }; - useEffect(() => { - async function fetchSubmissions() { - const res = await fetch(`/api/submissions?Page=${currentPage}&Limit=${cardsPerPage}&Sort=${ListSortConstants.ListSortDateDescending}`) - if (res.ok) { - setSubmissions(await res.json()) - } - } - - setTimeout(() => { - fetchSubmissions() - }, 50); - }, [currentPage]) - - if (submissions.Total < 0) { - return -
- Loading... -
-
- } - if (submissions.Total == 0) { return
From fa2d6115341e27ff1c5f1a844d937bcd760cdc0c Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 3 Jun 2025 17:11:53 -0700 Subject: [PATCH 05/22] submissions: allow submitter special permission to comment on their posts Previously only map council could comment. --- pkg/service/audit_events.go | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/pkg/service/audit_events.go b/pkg/service/audit_events.go index 4f28773..df6bd93 100644 --- a/pkg/service/audit_events.go +++ b/pkg/service/audit_events.go @@ -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.Submissions().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 { From 0ada77421fd9b7133e4e29ded57e48d28abfacc6 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 3 Jun 2025 17:17:59 -0700 Subject: [PATCH 06/22] fix bug --- pkg/service/audit_events.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/service/audit_events.go b/pkg/service/audit_events.go index df6bd93..1dfffda 100644 --- a/pkg/service/audit_events.go +++ b/pkg/service/audit_events.go @@ -33,7 +33,7 @@ func (svc *Service) CreateMapfixAuditComment(ctx context.Context, req api.Create if !has_role { // Submitter has special permission to comment on their mapfix - mapfix, err := svc.DB.Submissions().Get(ctx, params.MapfixID) + mapfix, err := svc.DB.Mapfixes().Get(ctx, params.MapfixID) if err != nil { return err } From cea6242dd7ca5932f9c1b4c4540a6a3d46cc9702 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 3 Jun 2025 18:42:21 -0700 Subject: [PATCH 07/22] web: fix api middleware --- web/src/middleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/middleware.ts b/web/src/middleware.ts index 61da104..b6220b5 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -13,7 +13,8 @@ export function middleware(request: NextRequest) { } const baseUrl = process.env.API_HOST.replace(/\/$/, ""); - const apiUrl = new URL(baseUrl + pathname + search); + const path = pathname.replace(/^\/api/, ""); + const apiUrl = new URL(baseUrl + path + search); return NextResponse.rewrite(apiUrl, { request }); } else if (pathname.startsWith("/auth")) { From 24964407bdceaaab98cd9b84620d870dce50f505 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 3 Jun 2025 21:55:19 -0700 Subject: [PATCH 08/22] web: use invalid id for submit to invoke error --- web/src/app/admin-submit/page.tsx | 2 +- web/src/app/maps/[mapId]/fix/page.tsx | 2 +- web/src/app/submit/page.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/app/admin-submit/page.tsx b/web/src/app/admin-submit/page.tsx index 52adc56..b04a0b9 100644 --- a/web/src/app/admin-submit/page.tsx +++ b/web/src/app/admin-submit/page.tsx @@ -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) diff --git a/web/src/app/maps/[mapId]/fix/page.tsx b/web/src/app/maps/[mapId]/fix/page.tsx index 2ae2def..e5e7389 100644 --- a/web/src/app/maps/[mapId]/fix/page.tsx +++ b/web/src/app/maps/[mapId]/fix/page.tsx @@ -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 }; diff --git a/web/src/app/submit/page.tsx b/web/src/app/submit/page.tsx index 7e89ba8..99afdae 100644 --- a/web/src/app/submit/page.tsx +++ b/web/src/app/submit/page.tsx @@ -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) From 7c95f8ddd02ce547f0edb69b2c84c2230db2f672 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 3 Jun 2025 22:07:39 -0700 Subject: [PATCH 09/22] validation: log errors --- validation/src/check_mapfix.rs | 18 +++++++++++------- validation/src/check_submission.rs | 18 +++++++++++------- validation/src/create_mapfix.rs | 3 +++ validation/src/create_submission.rs | 3 +++ validation/src/validate_mapfix.rs | 3 +++ validation/src/validate_submission.rs | 3 +++ 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/validation/src/check_mapfix.rs b/validation/src/check_mapfix.rs index de95811..20513b5 100644 --- a/validation/src/check_mapfix.rs +++ b/validation/src/check_mapfix.rs @@ -37,14 +37,18 @@ impl crate::message_handler::MessageHandler{ ErrorMessage:report, } ).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(()) diff --git a/validation/src/check_submission.rs b/validation/src/check_submission.rs index 4140539..4e0842f 100644 --- a/validation/src/check_submission.rs +++ b/validation/src/check_submission.rs @@ -38,14 +38,18 @@ impl crate::message_handler::MessageHandler{ ErrorMessage:report, } ).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(()) diff --git a/validation/src/create_mapfix.rs b/validation/src/create_mapfix.rs index 9980c53..0884efc 100644 --- a/validation/src/create_mapfix.rs +++ b/validation/src/create_mapfix.rs @@ -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(), diff --git a/validation/src/create_submission.rs b/validation/src/create_submission.rs index 3cfc3cc..56b3bd0 100644 --- a/validation/src/create_submission.rs +++ b/validation/src/create_submission.rs @@ -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(), diff --git a/validation/src/validate_mapfix.rs b/validation/src/validate_mapfix.rs index 56f29bf..e08197d 100644 --- a/validation/src/validate_mapfix.rs +++ b/validation/src/validate_mapfix.rs @@ -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, diff --git a/validation/src/validate_submission.rs b/validation/src/validate_submission.rs index c60a88b..5bf97b3 100644 --- a/validation/src/validate_submission.rs +++ b/validation/src/validate_submission.rs @@ -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, From 0d8937e896b43d5287bbe8f7b51169487d360280 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 14:38:16 -0700 Subject: [PATCH 10/22] refactor SpawnID --- validation/src/check.rs | 42 +++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/validation/src/check.rs b/validation/src/check.rs index 5ba1c64..c037cb7 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -87,27 +87,37 @@ impl std::str::FromStr for Zone{ } #[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, +} +struct StageElement{ + stage_id:StageID, + behaviour:StageElementBehaviour, } // Parse a SpawnTeleport from a part name -impl std::str::FromStr for SpawnTeleport{ +impl std::str::FromStr for StageElement{ type Err=IDParseError; fn from_str(s:&str)->Result{ // 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) } @@ -141,8 +151,8 @@ struct Counts<'a>{ mode_start_counts:HashMap>, mode_finish_counts:HashMap>, mode_anticheat_counts:HashMap>, - teleport_counts:HashMap>, - spawn_counts:HashMap, + teleport_counts:HashMap>, + spawn_counts:HashMap, wormhole_in_counts:HashMap, wormhole_out_counts:HashMap, } @@ -171,8 +181,8 @@ pub fn get_model_info<'a>(dom:&'a rbx_dom_weak::WeakDom,model_instance:&'a rbx_d } // 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, + 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 @@ -345,9 +355,9 @@ struct MapCheck<'a>{ // Spawn1 must exist spawn1:Result, // Check for dangling Teleport# (no associated Spawn#) - teleport_counts:SetDifferenceCheck>>, + teleport_counts:SetDifferenceCheck>>, // No duplicate Spawn# - spawn_counts:DuplicateCheck, + spawn_counts:DuplicateCheck, // Check for dangling WormholeIn# (no associated WormholeOut#) wormhole_in_counts:SetDifferenceCheck>, // No duplicate WormholeOut# (duplicate WormholeIn# ok) @@ -391,7 +401,7 @@ impl<'a> ModelInfo<'a>{ }; // Spawn1 must exist - let spawn1=if self.counts.spawn_counts.contains_key(&SpawnID::FIRST){ + let spawn1=if self.counts.spawn_counts.contains_key(&StageID::FIRST){ Ok(Exists) }else{ Err(Absent) @@ -584,7 +594,7 @@ impl std::fmt::Display for MapCheck<'_>{ } 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_comma_separated(f,context.iter(),|f,(StageID(spawn_id),count)| write!(f,"Spawn{spawn_id} ({count} duplicates)") )?; writeln!(f)?; From 845f8e69d94354e983a66bc1816646b49ac3d21f Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 14:46:27 -0700 Subject: [PATCH 11/22] refactor ModeID --- validation/src/check.rs | 47 ++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/validation/src/check.rs b/validation/src/check.rs index c037cb7..1cac473 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -47,38 +47,51 @@ 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), } // Parse a Zone from a part name -impl std::str::FromStr for Zone{ +impl std::str::FromStr for ModeElement{ type Err=IDParseError; fn from_str(s:&str)->Result{ 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)?))); + return Ok(Self{ + zone:Zone::Start, + mode_id: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)?))); + return Ok(Self{ + zone:Zone::Finish, + mode_id: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)?))); + return Ok(Self{ + zone:Zone::Anticheat, + mode_id:ModeID(captures[1].parse().map_err(IDParseError::ParseInt)?), + }); } Err(IDParseError::NoCaptures) } @@ -174,9 +187,9 @@ pub fn get_model_info<'a>(dom:&'a rbx_dom_weak::WeakDom,model_instance:&'a rbx_d 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()), + 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 From b0829bc1fc5342a2730e123a53e98235ef006f33 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 14:49:33 -0700 Subject: [PATCH 12/22] refactor WormholeID --- validation/src/check.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/validation/src/check.rs b/validation/src/check.rs index 1cac473..6d8c028 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -138,21 +138,31 @@ impl std::str::FromStr for StageElement{ #[derive(Clone,Copy,Debug,Hash,Eq,PartialEq)] struct WormholeID(u64); -enum Wormhole{ - In(WormholeID), - Out(WormholeID), +enum WormholeBehaviour{ + In, + Out, +} +struct WormholeElement{ + behaviour:WormholeBehaviour, + wormhole_id:WormholeID, } // Parse a Wormhole from a part name -impl std::str::FromStr for Wormhole{ +impl std::str::FromStr for WormholeElement{ type Err=IDParseError; fn from_str(s:&str)->Result{ 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) } @@ -200,8 +210,8 @@ pub fn get_model_info<'a>(dom:&'a rbx_dom_weak::WeakDom,model_instance:&'a rbx_d } // 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, + 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(_)=>(), } } From fdc024069859454e4d851b5d79cef2822385d9d3 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 3 Jun 2025 18:26:45 -0700 Subject: [PATCH 13/22] MapCheckSummary --- validation/src/check.rs | 412 ++++++++++++++++++++--------- validation/src/check_mapfix.rs | 2 +- validation/src/check_submission.rs | 2 +- 3 files changed, 284 insertions(+), 132 deletions(-) diff --git a/validation/src/check.rs b/validation/src/check.rs index 6d8c028..c963364 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -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{ @@ -98,6 +99,21 @@ impl std::str::FromStr for ModeElement{ } } } +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 StageID(u64); @@ -135,6 +151,14 @@ impl std::str::FromStr for StageElement{ 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); @@ -167,6 +191,14 @@ impl std::str::FromStr for WormholeElement{ 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)] @@ -479,7 +511,7 @@ impl<'a> ModelInfo<'a>{ } impl MapCheck<'_>{ - fn result(self)->Result{ + fn result(self)->Result>{ match self{ MapCheck{ model_class:StringCheck(Ok(())), @@ -503,156 +535,269 @@ impl MapCheck<'_>{ game_id, }) }, - other=>Err(other.to_string()), + other=>Err(other.itemize()), } } } -fn write_comma_separated( - f:&mut std::fmt::Formatter<'_>, - mut it:impl Iterator, - 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{ + iter:I, + separator:&'static str, +} +impl Separated{ + fn new(separator:&'static str,iter:I)->Self{ + Self{separator,iter} + } +} +impl std::fmt::Display for Separated + where + D:std::fmt::Display, + I:Clone, + I:Iterator, +{ + fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ + let mut it=self.iter.clone(); + if let Some(first)=it.next(){ + write!(f,"{first}")?; + for item in it{ + write!(f,"{}{item}",self.separator)?; + } + } + Ok(()) } - Ok(()) } -/// Write a zone string such as BonusStart -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), +struct Duplicates{ + display:D, + duplicates:usize, +} +impl Duplicates{ + fn new(display:D,duplicates:usize)->Self{ + Self{ + display, + duplicates, + } + } +} +impl std::fmt::Display for Duplicates{ + 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 std::fmt::Display for MapCheck<'_>{ - 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(Absent)=&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)?; +impl MapCheck<'_>{ + fn itemize(&self)->Result{ + 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}",()) } - 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)?; + }; + 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}",()) + } } - } - 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)?; + }; + 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.iter()); + summary_format!("DanglingTeleport","No matching Spawn for {plural}: {context}",()) } - } - if let Err(Absent)=&self.spawn1{ - writeln!(f,"Model has no Spawn1")?; - } - if let SetDifferenceCheck(Err(context))=&self.teleport_counts{ - for names in context.extra.values(){ - 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)?; + }; + 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}",()) } - } - if let DuplicateCheck(Err(DuplicateCheckContext(context)))=&self.spawn_counts{ - write!(f,"Duplicate Spawn: ")?; - write_comma_separated(f,context.iter(),|f,(StageID(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)?; + }; + 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}",()) } - 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(()) + }; + Ok(MapCheckList{checks:[ + 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:[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, + pub status:Result, pub version:u64, } @@ -689,7 +834,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.result(); + 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}) } diff --git a/validation/src/check_mapfix.rs b/validation/src/check_mapfix.rs index 20513b5..d8ed2fd 100644 --- a/validation/src/check_mapfix.rs +++ b/validation/src/check_mapfix.rs @@ -34,7 +34,7 @@ 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)?, // update the mapfix model status to request changes diff --git a/validation/src/check_submission.rs b/validation/src/check_submission.rs index 4e0842f..7e6463a 100644 --- a/validation/src/check_submission.rs +++ b/validation/src/check_submission.rs @@ -35,7 +35,7 @@ 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)?, // update the submission model status to request changes From 534598ba7080e8126fd11d5dfbf670eb8cd48115 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 17:13:24 -0700 Subject: [PATCH 14/22] box list to appease clippy --- validation/src/check.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/validation/src/check.rs b/validation/src/check.rs index c963364..88fcb16 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -758,7 +758,7 @@ impl MapCheck<'_>{ summary_format!("DuplicateWormholeOut","Duplicate WormholeOut: {context}",()) } }; - Ok(MapCheckList{checks:[ + Ok(MapCheckList{checks:Box::new([ model_class, model_name, display_name, @@ -775,13 +775,13 @@ impl MapCheck<'_>{ extra_wormhole_in, missing_wormhole_in, duplicate_wormhole_out, - ]}) + ])}) } } #[derive(serde::Serialize)] struct MapCheckList{ - checks:[CheckSummary;16], + checks:Box<[CheckSummary;16]>, } impl MapCheckList{ fn summary(&self)->String{ From 489a8c9c107d1e38f4adac43adb1c0a910fb7ba7 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 17:22:53 -0700 Subject: [PATCH 15/22] web: rename force submit to admin submit --- web/src/app/submissions/[submissionId]/_reviewButtons.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/app/submissions/[submissionId]/_reviewButtons.tsx b/web/src/app/submissions/[submissionId]/_reviewButtons.tsx index a5eac74..71c1c0d 100644 --- a/web/src/app/submissions/[submissionId]/_reviewButtons.tsx +++ b/web/src/app/submissions/[submissionId]/_reviewButtons.tsx @@ -10,7 +10,7 @@ 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, 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 +122,7 @@ 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 }); } // you can't review your own submission! // note that this means there needs to be more than one person with SubmissionReview From 80f30d20fa3693e38fdd63f50c35512fe76a110d Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 17:23:59 -0700 Subject: [PATCH 16/22] web: introduce Force Submit button --- web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx | 5 +++++ web/src/app/submissions/[submissionId]/_reviewButtons.tsx | 2 ++ 2 files changed, 7 insertions(+) diff --git a/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx b/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx index b695f45..70b93fe 100644 --- a/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx +++ b/web/src/app/mapfixes/[mapfixId]/_reviewButtons.tsx @@ -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) { diff --git a/web/src/app/submissions/[submissionId]/_reviewButtons.tsx b/web/src/app/submissions/[submissionId]/_reviewButtons.tsx index 71c1c0d..b388cf1 100644 --- a/web/src/app/submissions/[submissionId]/_reviewButtons.tsx +++ b/web/src/app/submissions/[submissionId]/_reviewButtons.tsx @@ -11,6 +11,7 @@ interface ReviewAction { const ReviewActions = { Submit: {name:"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, @@ -123,6 +124,7 @@ export default function ReviewButtons(props: ReviewId) { // you can force submit a map in ChangesRequested status if (!is_submitter && submissionStatus === SubmissionStatus.ChangesRequested) { 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 From 555844e6ee87d86dea14dd0b9af2ec8ab59555d5 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 17:30:10 -0700 Subject: [PATCH 17/22] openapi: bypass-submit endpoints --- openapi.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 2879ae8..8b656e4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -384,6 +384,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 @@ -816,6 +833,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 From 4055ef550e2752d02fc1a7f571466232b61d55e0 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 17:32:44 -0700 Subject: [PATCH 18/22] openapi: generate --- pkg/api/oas_client_gen.go | 260 +++++++++++++++ pkg/api/oas_handlers_gen.go | 390 +++++++++++++++++++++++ pkg/api/oas_operations_gen.go | 2 + pkg/api/oas_parameters_gen.go | 166 ++++++++++ pkg/api/oas_request_decoders_gen.go | 41 ++- pkg/api/oas_response_decoders_gen.go | 120 +++++++ pkg/api/oas_response_encoders_gen.go | 14 + pkg/api/oas_router_gen.go | 92 ++++++ pkg/api/oas_schemas_gen.go | 17 + pkg/api/oas_security_gen.go | 46 +++ pkg/api/oas_server_gen.go | 12 + pkg/api/oas_unimplemented_gen.go | 18 ++ pkg/internal/oas_request_decoders_gen.go | 17 +- 13 files changed, 1165 insertions(+), 30 deletions(-) diff --git a/pkg/api/oas_client_gen.go b/pkg/api/oas_client_gen.go index 7951723..b5fa438 100644 --- a/pkg/api/oas_client_gen.go +++ b/pkg/api/oas_client_gen.go @@ -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. diff --git a/pkg/api/oas_handlers_gen.go b/pkg/api/oas_handlers_gen.go index 13081b7..a2677d0 100644 --- a/pkg/api/oas_handlers_gen.go +++ b/pkg/api/oas_handlers_gen.go @@ -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. diff --git a/pkg/api/oas_operations_gen.go b/pkg/api/oas_operations_gen.go index bb64e95..0ed67d8 100644 --- a/pkg/api/oas_operations_gen.go +++ b/pkg/api/oas_operations_gen.go @@ -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" diff --git a/pkg/api/oas_parameters_gen.go b/pkg/api/oas_parameters_gen.go index f434a5f..bcfd93d 100644 --- a/pkg/api/oas_parameters_gen.go +++ b/pkg/api/oas_parameters_gen.go @@ -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. diff --git a/pkg/api/oas_request_decoders_gen.go b/pkg/api/oas_request_decoders_gen.go index a53e8e7..24ca5bf 100644 --- a/pkg/api/oas_request_decoders_gen.go +++ b/pkg/api/oas_request_decoders_gen.go @@ -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")) diff --git a/pkg/api/oas_response_decoders_gen.go b/pkg/api/oas_response_decoders_gen.go index a017f83..f1227af 100644 --- a/pkg/api/oas_response_decoders_gen.go +++ b/pkg/api/oas_response_decoders_gen.go @@ -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: diff --git a/pkg/api/oas_response_encoders_gen.go b/pkg/api/oas_response_encoders_gen.go index 151b325..6279a81 100644 --- a/pkg/api/oas_response_encoders_gen.go +++ b/pkg/api/oas_response_encoders_gen.go @@ -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)) diff --git a/pkg/api/oas_router_gen.go b/pkg/api/oas_router_gen.go index 00ff848..d74b7b5 100644 --- a/pkg/api/oas_router_gen.go +++ b/pkg/api/oas_router_gen.go @@ -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" { diff --git a/pkg/api/oas_schemas_gen.go b/pkg/api/oas_schemas_gen.go index c55d7b4..6d355f5 100644 --- a/pkg/api/oas_schemas_gen.go +++ b/pkg/api/oas_schemas_gen.go @@ -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{} diff --git a/pkg/api/oas_security_gen.go b/pkg/api/oas_security_gen.go index 5f1eac3..cb72d81 100644 --- a/pkg/api/oas_security_gen.go +++ b/pkg/api/oas_security_gen.go @@ -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 diff --git a/pkg/api/oas_server_gen.go b/pkg/api/oas_server_gen.go index 08df749..f2f961b 100644 --- a/pkg/api/oas_server_gen.go +++ b/pkg/api/oas_server_gen.go @@ -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. diff --git a/pkg/api/oas_unimplemented_gen.go b/pkg/api/oas_unimplemented_gen.go index 9f0e804..5f935de 100644 --- a/pkg/api/oas_unimplemented_gen.go +++ b/pkg/api/oas_unimplemented_gen.go @@ -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. diff --git a/pkg/internal/oas_request_decoders_gen.go b/pkg/internal/oas_request_decoders_gen.go index a33ac8d..016e05d 100644 --- a/pkg/internal/oas_request_decoders_gen.go +++ b/pkg/internal/oas_request_decoders_gen.go @@ -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")) From a990ed458ced0e85e6d5836e1860feab0907e9b3 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 17:41:40 -0700 Subject: [PATCH 19/22] submissions: optimize trigger-submit --- pkg/service/submissions.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pkg/service/submissions.go b/pkg/service/submissions.go index 3ec4516..142053e 100644 --- a/pkg/service/submissions.go +++ b/pkg/service/submissions.go @@ -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 From 2acc30e18c3de2329105145da2fee2cf6ed8479a Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 17:41:52 -0700 Subject: [PATCH 20/22] submissions: add bypass-submit --- pkg/service/mapfixes.go | 70 ++++++++++++++++++++++++++++++++++++++ pkg/service/submissions.go | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/pkg/service/mapfixes.go b/pkg/service/mapfixes.go index 1712afd..4e69b52 100644 --- a/pkg/service/mapfixes.go +++ b/pkg/service/mapfixes.go @@ -539,6 +539,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. diff --git a/pkg/service/submissions.go b/pkg/service/submissions.go index 142053e..bfc60fb 100644 --- a/pkg/service/submissions.go +++ b/pkg/service/submissions.go @@ -646,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. From 3da8e414e621f62ddc6c148e3bb6b1a65b87f0a2 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 17:52:23 -0700 Subject: [PATCH 21/22] submissions: fix comment --- pkg/service/submissions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/service/submissions.go b/pkg/service/submissions.go index bfc60fb..b2f0538 100644 --- a/pkg/service/submissions.go +++ b/pkg/service/submissions.go @@ -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, From 90d13d28ae94f28bfaa366a7440e1dd6f5e66ede Mon Sep 17 00:00:00 2001 From: Quaternions Date: Wed, 4 Jun 2025 18:18:18 -0700 Subject: [PATCH 22/22] use closure instead of iterator --- validation/src/check.rs | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/validation/src/check.rs b/validation/src/check.rs index 88fcb16..2a6569d 100644 --- a/validation/src/check.rs +++ b/validation/src/check.rs @@ -540,23 +540,23 @@ impl MapCheck<'_>{ } } -struct Separated{ - iter:I, +struct Separated{ + f:F, separator:&'static str, } -impl Separated{ - fn new(separator:&'static str,iter:I)->Self{ - Self{separator,iter} +impl Separated{ + fn new(separator:&'static str,f:F)->Self{ + Self{separator,f} } } -impl std::fmt::Display for Separated +impl std::fmt::Display for Separated where D:std::fmt::Display, - I:Clone, - I:Iterator, + I:IntoIterator, + F:Fn()->I, { fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{ - let mut it=self.iter.clone(); + let mut it=(self.f)().into_iter(); if let Some(first)=it.next(){ write!(f,"{first}")?; for item in it{ @@ -661,7 +661,7 @@ impl MapCheck<'_>{ 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)| + 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}",()) @@ -674,7 +674,7 @@ impl MapCheck<'_>{ 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)| + 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}",()) @@ -683,7 +683,7 @@ impl MapCheck<'_>{ CheckSummary::passed("MissingFinish") }else{ let plural=if context.missing.len()==1{"zone"}else{"zones"}; - let context=Separated::new(", ",context.missing.iter().map(|&mode_id| + let context=Separated::new(", ",||context.missing.iter().map(|&mode_id| ModeElement{zone:Zone::Finish,mode_id} )); summary_format!("MissingFinish","Missing finish {plural}: {context}",()) @@ -697,7 +697,7 @@ impl MapCheck<'_>{ 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)| + 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}",()) @@ -713,14 +713,14 @@ impl MapCheck<'_>{ 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.iter()); + 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)| + 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}",()) @@ -732,7 +732,7 @@ impl MapCheck<'_>{ if context.extra.is_empty(){ CheckSummary::passed("ExtraWormholeIn") }else{ - let context=Separated::new(", ",context.extra.iter().map(|(&wormhole_id,_names)| + 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}",()) @@ -742,7 +742,7 @@ impl MapCheck<'_>{ }else{ // This counts WormholeIn objects, but // flipped logic is easier to understand - let context=Separated::new(", ",context.missing.iter().map(|&wormhole_id| + 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}",()) @@ -752,7 +752,7 @@ impl MapCheck<'_>{ 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)| + 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}",()) @@ -785,7 +785,7 @@ struct MapCheckList{ } impl MapCheckList{ fn summary(&self)->String{ - Separated::new("; ",self.checks.iter().filter_map(|check| + Separated::new("; ",||self.checks.iter().filter_map(|check| (!check.passed).then_some(check.summary.as_str()) )).to_string() }