Validate Asset Version + Website QoL + Script Names Fix #193

Merged
Quaternions merged 4 commits from staging into master 2025-06-10 23:53:08 +00:00
12 changed files with 277 additions and 229 deletions

View File

@@ -84,6 +84,7 @@ func (svc *Service) ListScripts(ctx context.Context, params api.ListScriptsParam
for _, item := range items {
resp = append(resp, api.Script{
ID: item.ID,
Name: item.Name,
Hash: model.HashFormat(uint64(item.Hash)),
Source: item.Source,
ResourceType: int32(item.ResourceType),

View File

@@ -71,6 +71,7 @@ func (svc *Service) ListScripts(ctx context.Context, params api.ListScriptsParam
for _, item := range items {
resp = append(resp, api.Script{
ID: item.ID,
Name: item.Name,
Hash: model.HashFormat(uint64(item.Hash)),
Source: item.Source,
ResourceType: int32(item.ResourceType),

View File

@@ -34,6 +34,12 @@ fn hash_source(source:&str)->String{
#[allow(dead_code)]
#[derive(Debug)]
pub enum Error{
ModelInfoDownload(rbx_asset::cloud::GetError),
CreatorTypeMustBeUser,
RevisionMismatch{
current:u64,
submitted:u64,
},
ScriptFlaggedIllegalKeyword(String),
ScriptBlocked(Option<submissions_api::types::ScriptID>),
ScriptNotYetReviewed(Option<submissions_api::types::ScriptID>),
@@ -90,6 +96,24 @@ impl From<crate::nats_types::ValidateSubmissionRequest> for ValidateRequest{
impl crate::message_handler::MessageHandler{
pub async fn validate_inner(&self,validate_info:ValidateRequest)->Result<(),Error>{
// discover asset creator and latest version
let info=self.cloud_context.get_asset_info(
rbx_asset::cloud::GetAssetLatestRequest{asset_id:validate_info.ModelID}
).await.map_err(Error::ModelInfoDownload)?;
// reject models created by a group
let rbx_asset::cloud::Creator::userId(_user_id)=info.creationContext.creator else{
return Err(Error::CreatorTypeMustBeUser);
};
// Has the map been updated since it was submitted?
if info.revisionId!=validate_info.ModelVersion{
return Err(Error::RevisionMismatch{
current:info.revisionId,
submitted:validate_info.ModelVersion,
});
}
// download the map model
let maybe_gzip=download_asset_version(&self.cloud_context,rbx_asset::cloud::GetAssetVersionRequest{
asset_id:validate_info.ModelID,

View File

@@ -38,21 +38,29 @@ export default function CommentsTabPanel({
{activeTab === 0 && (
<>
<Stack spacing={2} sx={{ mb: 3 }}>
{commentEvents.map((event, index) => (
<CommentItem
key={index}
event={event}
validatorUser={validatorUser}
/>
))}
{commentEvents.length > 0 ? (
commentEvents.map((event, index) => (
<CommentItem
key={index}
event={event}
validatorUser={validatorUser}
/>
))
) : (
<Box sx={{ textAlign: 'center', py: 2, color: 'text.secondary' }}>
No Comments
</Box>
)}
</Stack>
<CommentInput
newComment={newComment}
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
userId={userId}
/>
{userId !== null && (
<CommentInput
newComment={newComment}
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
userId={userId}
/>
)}
</>
)}
</Box>

View File

@@ -2,7 +2,8 @@ import React from 'react';
import { Button, Stack } from '@mui/material';
import {MapfixInfo } from "@/app/ts/Mapfix";
import {hasRole, Roles, RolesConstants} from "@/app/ts/Roles";
import {SubmissionInfo, SubmissionStatus} from "@/app/ts/Submission";
import {SubmissionInfo} from "@/app/ts/Submission";
import {Status, StatusMatches} from "@/app/ts/Status";
interface ReviewAction {
name: string,
@@ -51,38 +52,18 @@ const ReviewButtons: React.FC<ReviewButtonsProps> = ({
const is_submitter = userId === item.Submitter;
const status = item.StatusID;
// Helper function to check status regardless of which enum type it is
const statusMatches = (statusValues: number[]) => {
return statusValues.includes(status);
};
// Create status constants that work with both types
const Status = {
UnderConstruction: SubmissionStatus.UnderConstruction,
ChangesRequested: SubmissionStatus.ChangesRequested,
Submitting: SubmissionStatus.Submitting,
Submitted: SubmissionStatus.Submitted,
AcceptedUnvalidated: SubmissionStatus.AcceptedUnvalidated,
Validating: SubmissionStatus.Validating,
Validated: SubmissionStatus.Validated,
Uploading: SubmissionStatus.Uploading,
Uploaded: SubmissionStatus.Uploaded,
Rejected: SubmissionStatus.Rejected,
Release: SubmissionStatus.Released
};
const reviewRole = type === "submission" ? RolesConstants.SubmissionReview : RolesConstants.MapfixReview;
const uploadRole = type === "submission" ? RolesConstants.SubmissionUpload : RolesConstants.MapfixUpload;
if (is_submitter) {
if (statusMatches([Status.UnderConstruction, Status.ChangesRequested])) {
if (StatusMatches(status, [Status.UnderConstruction, Status.ChangesRequested])) {
buttons.push({
action: ReviewActions.Submit,
color: "primary"
});
}
if (statusMatches([Status.Submitted, Status.ChangesRequested])) {
if (StatusMatches(status, [Status.Submitted, Status.ChangesRequested])) {
buttons.push({
action: ReviewActions.Revoke,
color: "error"
@@ -119,7 +100,7 @@ const ReviewButtons: React.FC<ReviewButtonsProps> = ({
});
}
if (statusMatches([Status.Validated, Status.AcceptedUnvalidated, Status.Submitted]) && !is_submitter) {
if (StatusMatches(status, [Status.Validated, Status.AcceptedUnvalidated, Status.Submitted]) && !is_submitter) {
buttons.push({
action: ReviewActions.RequestChanges,
color: "warning"

View File

@@ -1,7 +1,8 @@
import {Typography, Box, Avatar} from "@mui/material";
import {Typography, Box, Avatar, keyframes} from "@mui/material";
import { StatusChip } from "@/app/_components/statusChip";
import { SubmissionStatus } from "@/app/ts/Submission";
import { MapfixStatus } from "@/app/ts/Mapfix";
import {Status, StatusMatches} from "@/app/ts/Status";
type StatusIdType = SubmissionStatus | MapfixStatus;
@@ -13,13 +14,45 @@ interface ReviewItemHeaderProps {
}
export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }: ReviewItemHeaderProps) => {
const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]);
const pulse = keyframes`
0%, 100% { opacity: 0.2; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
`;
return (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
{displayName}
</Typography>
<StatusChip status={statusId as number} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isProcessing && (
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mr: 0.5,
height: 24
}}>
{[0, 1, 2].map((i) => (
<Box
key={i}
sx={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: 'primary.main',
animation: `${pulse} 1.4s ease-in-out infinite`,
animationDelay: `${i * 0.2}s`,
}}
/>
))}
</Box>
)}
<StatusChip status={statusId as number} />
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>

View File

@@ -0,0 +1,122 @@
import {useState, useEffect, useCallback} from "react";
import {Roles, RolesConstants} from "@/app/ts/Roles";
import {AuditEvent} from "@/app/ts/AuditEvent";
import {Status, StatusMatches} from "@/app/ts/Status";
import {MapfixInfo} from "@/app/ts/Mapfix";
import {SubmissionInfo} from "@/app/ts/Submission";
type ReviewItemType = "submissions" | "mapfixes";
type ReviewData = MapfixInfo | SubmissionInfo;
interface UseReviewDataProps {
itemType: ReviewItemType;
itemId: string | number;
}
interface UseReviewDataResult {
data: ReviewData | null;
auditEvents: AuditEvent[];
roles: Roles;
user: number | null;
loading: boolean;
error: string | null;
refreshData: (skipLoadingState?: boolean) => Promise<void>;
}
export function useReviewData({itemType, itemId}: UseReviewDataProps): UseReviewDataResult {
const [data, setData] = useState<ReviewData | null>(null);
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [user, setUser] = useState<number | null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const fetchData = useCallback(async (skipLoadingState = false) => {
try {
if (!skipLoadingState) {
setLoading(true);
}
setError(null);
try {
const [reviewData, auditData] = await Promise.all([
fetch(`/api/${itemType}/${itemId}`).then(res => {
if (!res.ok) throw new Error(`Failed to fetch ${itemType.slice(0, -1)}: ${res.status}`);
return res.json();
}),
fetch(`/api/${itemType}/${itemId}/audit-events?Page=1&Limit=100`).then(res => {
if (!res.ok) throw new Error(`Failed to fetch audit events: ${res.status}`);
return res.json();
})
]);
setData(reviewData);
setAuditEvents(auditData);
} catch (error) {
console.error(`Error fetching critical ${itemType} data:`, error);
throw error;
}
try {
const rolesResponse = await fetch("/api/session/roles");
if (rolesResponse.ok) {
const rolesData = await rolesResponse.json();
setRoles(rolesData.Roles);
} else {
console.warn(`Failed to fetch roles: ${rolesResponse.status}`);
setRoles(RolesConstants.Empty);
}
} catch (error) {
console.warn("Error fetching roles data:", error);
setRoles(RolesConstants.Empty);
}
try {
const userResponse = await fetch("/api/session/user");
if (userResponse.ok) {
const userData = await userResponse.json();
setUser(userData.UserID);
} else {
console.warn(`Failed to fetch user: ${userResponse.status}`);
setUser(null);
}
} catch (error) {
console.warn("Error fetching user data:", error);
setUser(null);
}
} catch (error) {
console.error("Error fetching review data:", error);
setError(error instanceof Error ? error.message : `Failed to load ${itemType.slice(0, -1)} details`);
} finally {
if (!skipLoadingState) {
setLoading(false);
}
}
}, [itemId, itemType]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
if (data) {
if (StatusMatches(data.StatusID, [Status.Uploading, Status.Submitting, Status.Validating])) {
const intervalId = setInterval(() => {
fetchData(true);
}, 5000);
return () => clearInterval(intervalId);
}
}
}, [data, itemType, fetchData]);
return {
data,
auditEvents,
roles,
user,
loading,
error,
refreshData: fetchData
};
}

View File

@@ -1,10 +1,8 @@
"use client";
import {AuditEvent} from "@/app/ts/AuditEvent";
import { Roles, RolesConstants } from "@/app/ts/Roles";
import Webpage from "@/app/_components/webpage";
import { useParams, useRouter } from "next/navigation";
import {useState, useEffect, useCallback} from "react";
import {useState} from "react";
import Link from "next/link";
// MUI Components
@@ -25,10 +23,9 @@ import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import CommentsAndAuditSection from "@/app/_components/comments/CommentsAndAuditSection";
import {ReviewItem} from "@/app/_components/review/ReviewItem";
import {ErrorDisplay} from "@/app/_components/ErrorDisplay";
import {MapfixInfo} from "@/app/ts/Mapfix";
import ReviewButtons from "@/app/_components/review/ReviewButtons";
// Review action definitions
import {useReviewData} from "@/app/hooks/useReviewData";
import {MapfixInfo} from "@/app/ts/Mapfix";
interface SnackbarState {
open: boolean;
@@ -39,14 +36,7 @@ interface SnackbarState {
export default function MapfixDetailsPage() {
const { mapfixId } = useParams<{ mapfixId: string }>();
const router = useRouter();
const [mapfix, setMapfix] = useState<MapfixInfo | null>(null);
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const [user, setUser] = useState<number|null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const [showBeforeImage, setShowBeforeImage] = useState(false);
const [snackbar, setSnackbar] = useState<SnackbarState>({
open: false,
@@ -70,50 +60,19 @@ export default function MapfixDetailsPage() {
const validatorUser = 9223372036854776000;
const fetchData = useCallback(async (skipLoadingState = false) => {
try {
if (!skipLoadingState) {
setLoading(true);
}
setError(null);
const [mapfixData, auditData, rolesData, userData] = await Promise.all([
fetch(`/api/mapfixes/${mapfixId}`).then(res => {
if (!res.ok) throw new Error(`Failed to fetch mapfix: ${res.status}`);
return res.json();
}),
fetch(`/api/mapfixes/${mapfixId}/audit-events?Page=1&Limit=100`).then(res => {
if (!res.ok) throw new Error(`Failed to fetch audit events: ${res.status}`);
return res.json();
}),
fetch("/api/session/roles").then(res => {
if (!res.ok) throw new Error(`Failed to fetch roles: ${res.status}`);
return res.json();
}),
fetch("/api/session/user").then(res => {
if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
return res.json();
})
]);
setMapfix(mapfixData);
setAuditEvents(auditData);
setRoles(rolesData.Roles);
setUser(userData.UserID);
} catch (error) {
console.error("Error fetching data:", error);
setError(error instanceof Error ? error.message : "Failed to load mapfix details");
} finally {
if (!skipLoadingState) {
setLoading(false);
}
}
}, [mapfixId]);
// Fetch mapfix data and audit events
useEffect(() => {
fetchData();
}, [fetchData]);
const {
data: mapfixData,
auditEvents,
roles,
user,
loading,
error,
refreshData
} = useReviewData({
itemType: 'mapfixes',
itemId: mapfixId
});
const mapfix = mapfixData as MapfixInfo;
// Handle review button actions
async function handleReviewAction(action: string, mapfixId: number) {
@@ -134,14 +93,14 @@ export default function MapfixDetailsPage() {
showSnackbar(`Successfully completed action: ${action}`, "success");
// Reload data instead of refreshing the page
fetchData(true);
await refreshData(true);
} catch (error) {
console.error("Error updating mapfix status:", error);
showSnackbar(error instanceof Error ? error.message : "Failed to update mapfix", 'error');
// Reload data instead of refreshing the page
fetchData(true);
await refreshData(true);
}
}
@@ -172,15 +131,10 @@ export default function MapfixDetailsPage() {
// Clear comment input
setNewComment("");
// Refresh audit events to show the new comment
const auditData = await fetch(`/api/mapfixes/${mapfixId}/audit-events?Page=1&Limit=100`);
if (auditData.ok) {
const updatedAuditEvents = await auditData.json();
setAuditEvents(updatedAuditEvents);
}
await refreshData(true);
} catch (error) {
console.error("Error submitting comment:", error);
setError(error instanceof Error ? error.message : "Failed to submit comment");
showSnackbar(error instanceof Error ? error.message : "Failed to submit comment", "error");
}
};

View File

@@ -1,11 +1,7 @@
"use client";
import { SubmissionInfo } from "@/app/ts/Submission";
import {AuditEvent} from "@/app/ts/AuditEvent";
import { Roles, RolesConstants } from "@/app/ts/Roles";
import Webpage from "@/app/_components/webpage";
import { useParams, useRouter } from "next/navigation";
import {useState, useEffect, useCallback} from "react";
import {useState} from "react";
import Link from "next/link";
// MUI Components
@@ -27,6 +23,8 @@ import CommentsAndAuditSection from "@/app/_components/comments/CommentsAndAudit
import {ReviewItem} from "@/app/_components/review/ReviewItem";
import {ErrorDisplay} from "@/app/_components/ErrorDisplay";
import ReviewButtons from "@/app/_components/review/ReviewButtons";
import {useReviewData} from "@/app/hooks/useReviewData";
import {SubmissionInfo} from "@/app/ts/Submission";
interface SnackbarState {
open: boolean;
@@ -37,14 +35,7 @@ interface SnackbarState {
export default function SubmissionDetailsPage() {
const { submissionId } = useParams<{ submissionId: string }>();
const router = useRouter();
const [submission, setSubmission] = useState<SubmissionInfo | null>(null);
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const [user, setUser] = useState<number|null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const [snackbar, setSnackbar] = useState<SnackbarState>({
open: false,
message: null,
@@ -68,50 +59,19 @@ export default function SubmissionDetailsPage() {
const validatorUser = 9223372036854776000;
const fetchData = useCallback(async (skipLoadingState = false) => {
try {
if (!skipLoadingState) {
setLoading(true);
}
setError(null);
const [submissionData, auditData, rolesData, userData] = await Promise.all([
fetch(`/api/submissions/${submissionId}`).then(res => {
if (!res.ok) throw new Error(`Failed to fetch submission: ${res.status}`);
return res.json();
}),
fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`).then(res => {
if (!res.ok) throw new Error(`Failed to fetch audit events: ${res.status}`);
return res.json();
}),
fetch("/api/session/roles").then(res => {
if (!res.ok) throw new Error(`Failed to fetch roles: ${res.status}`);
return res.json();
}),
fetch("/api/session/user").then(res => {
if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
return res.json();
})
]);
setSubmission(submissionData);
setAuditEvents(auditData);
setRoles(rolesData.Roles);
setUser(userData.UserID);
} catch (error) {
console.error("Error fetching data:", error);
setError(error instanceof Error ? error.message : "Failed to load submission details");
} finally {
if (!skipLoadingState) {
setLoading(false);
}
}
}, [submissionId]);
// Fetch submission data and audit events
useEffect(() => {
fetchData();
}, [fetchData]);
const {
data: submissionData,
auditEvents,
roles,
user,
loading,
error,
refreshData
} = useReviewData({
itemType: 'submissions',
itemId: submissionId
});
const submission = submissionData as SubmissionInfo;
// Handle review button actions
async function handleReviewAction(action: string, submissionId: number) {
@@ -132,14 +92,14 @@ export default function SubmissionDetailsPage() {
showSnackbar(`Successfully completed action: ${action}`, "success");
// Reload data instead of refreshing the page
fetchData(true);
await refreshData(true);
} catch (error) {
console.error("Error updating submission status:", error);
showSnackbar(error instanceof Error ? error.message : "Failed to update submission", 'error');
// Reload data instead of refreshing the page
fetchData(true);
await refreshData(true);
}
}
@@ -170,15 +130,10 @@ export default function SubmissionDetailsPage() {
// Clear comment input
setNewComment("");
// Refresh audit events to show the new comment
const auditData = await fetch(`/api/submissions/${submissionId}/audit-events?Page=1&Limit=100`);
if (auditData.ok) {
const updatedAuditEvents = await auditData.json();
setAuditEvents(updatedAuditEvents);
}
await refreshData(true);
} catch (error) {
console.error("Error submitting comment:", error);
setError(error instanceof Error ? error.message : "Failed to submit comment");
showSnackbar(error instanceof Error ? error.message : "Failed to submit comment", "error");
}
};

View File

@@ -1,24 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { errorImageResponse } from '@/app/lib/errorImageResponse';
const cache = new Map<number, { buffer: Buffer; expires: number }>();
const CACHE_TTL = 15 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [key, value] of cache.entries()) {
if (value.expires <= now) {
cache.delete(key);
}
}
}, 60 * 5 * 1000);
export async function GET(
request: NextRequest,
context: { params: Promise<{ assetId: number }> }
): Promise<NextResponse> {
const { assetId } = await context.params;
if (!assetId) {
return errorImageResponse(400, {
message: "Missing asset ID",
@@ -39,19 +27,6 @@ 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}`
@@ -70,26 +45,12 @@ export async function GET(
})
}
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw new Error(`Failed to fetch the image [${imageResponse.status}]`)
}
// Redirect to the actual image URL instead of proxying
return NextResponse.redirect(imageUrl);
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 (err) {
return errorImageResponse(500, {
message: `Failed to fetch or process thumbnail: ${err}`,
})
message: `Failed to fetch thumbnail URL: ${err}`,
})
}
}

View File

@@ -32,24 +32,13 @@ export async function GET(
);
}
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw new Error('Failed to fetch the image');
}
// Redirect to the image URL instead of proxying
return NextResponse.redirect(imageUrl);
const arrayBuffer = await imageResponse.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/png',
'Content-Length': buffer.length.toString(),
},
});
} catch {
return NextResponse.json(
{ error: 'Failed to fetch or process avatar headshot' },
{ error: 'Failed to fetch avatar headshot URL' },
{ status: 500 }
);
}
}
}

19
web/src/app/ts/Status.ts Normal file
View File

@@ -0,0 +1,19 @@
import {SubmissionStatus} from "@/app/ts/Submission";
export const Status = {
UnderConstruction: SubmissionStatus.UnderConstruction,
ChangesRequested: SubmissionStatus.ChangesRequested,
Submitting: SubmissionStatus.Submitting,
Submitted: SubmissionStatus.Submitted,
AcceptedUnvalidated: SubmissionStatus.AcceptedUnvalidated,
Validating: SubmissionStatus.Validating,
Validated: SubmissionStatus.Validated,
Uploading: SubmissionStatus.Uploading,
Uploaded: SubmissionStatus.Uploaded,
Rejected: SubmissionStatus.Rejected,
Release: SubmissionStatus.Released
};
export const StatusMatches = (status: number, statusValues: number[]) => {
return statusValues.includes(status);
};