From 8ca7f990989d4225b7b3a4a179037daaa6890e05 Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Sun, 29 Jun 2025 19:01:35 -0600 Subject: [PATCH 01/12] pushing ai changes before i lose them --- web/src/app/_components/mapCard.tsx | 27 +-- web/src/app/lib/thumbnailLoader.ts | 3 - web/src/app/mapfixes/[mapfixId]/page.tsx | 53 ++++-- web/src/app/mapfixes/page.tsx | 17 ++ web/src/app/maps/[mapId]/page.tsx | 39 +++-- web/src/app/maps/page.tsx | 44 +++-- .../app/submissions/[submissionId]/page.tsx | 42 ++++- web/src/app/submissions/page.tsx | 17 ++ .../app/thumbnails/asset/[assetId]/route.tsx | 158 +++++++++++++----- 9 files changed, 302 insertions(+), 98 deletions(-) delete mode 100644 web/src/app/lib/thumbnailLoader.ts diff --git a/web/src/app/_components/mapCard.tsx b/web/src/app/_components/mapCard.tsx index c54030a..6b37b92 100644 --- a/web/src/app/_components/mapCard.tsx +++ b/web/src/app/_components/mapCard.tsx @@ -1,5 +1,5 @@ import React from "react"; -import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, Divider, Grid, Typography} from "@mui/material"; +import {Avatar, Box, Card, CardActionArea, CardContent, CardMedia, CircularProgress, Divider, Grid, Typography} from "@mui/material"; import {Explore, Person2} from "@mui/icons-material"; import {StatusChip} from "@/app/_components/statusChip"; @@ -14,6 +14,7 @@ interface MapCardProps { gameID: number; created: number; type: 'mapfix' | 'submission'; + thumbnailUrl?: string; } const CARD_WIDTH = 270; @@ -40,15 +41,21 @@ export function MapCard(props: MapCardProps) { }} href={`/${props.type === 'submission' ? 'submissions' : 'mapfixes'}/${props.id}`}> - + {props.thumbnailUrl ? ( + + ) : ( + + + + )} { - return `${src}?w=${width}&q=${quality || 75}`; -}; \ No newline at end of file diff --git a/web/src/app/mapfixes/[mapfixId]/page.tsx b/web/src/app/mapfixes/[mapfixId]/page.tsx index bc05191..738ea87 100644 --- a/web/src/app/mapfixes/[mapfixId]/page.tsx +++ b/web/src/app/mapfixes/[mapfixId]/page.tsx @@ -2,7 +2,7 @@ import Webpage from "@/app/_components/webpage"; import { useParams, useRouter } from "next/navigation"; -import {useState} from "react"; +import {useEffect, useState} from "react"; import Link from "next/link"; // MUI Components @@ -17,6 +17,7 @@ import { CardMedia, Snackbar, Alert, + CircularProgress, } from "@mui/material"; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; @@ -44,6 +45,8 @@ export default function MapfixDetailsPage() { message: null, severity: 'success' }); + const [thumbnailUrls, setThumbnailUrls] = useState>({}); + const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => { setSnackbar({ open: true, @@ -76,6 +79,18 @@ export default function MapfixDetailsPage() { useTitle(mapfix ? `${mapfix.DisplayName} Mapfix` : 'Loading Mapfix...'); + // Fetch thumbnails for mapfix images + useEffect(() => { + if (!mapfix?.AssetID && !mapfix?.TargetAssetID) { + setThumbnailUrls({}); + return; + } + const ids = [mapfix.TargetAssetID, mapfix.AssetID].filter(Boolean).join(","); + fetch(`/thumbnails/batch?ids=${ids}`) + .then(res => res.json()) + .then(data => setThumbnailUrls(data)); + }, [mapfix?.AssetID, mapfix?.TargetAssetID]); + // Handle review button actions async function handleReviewAction(action: string, mapfixId: number) { try { @@ -220,12 +235,18 @@ export default function MapfixDetailsPage() { transition: 'opacity 0.5s ease-in-out' }} > - + {thumbnailUrls[mapfix.TargetAssetID] ? ( + + ) : ( + + + + )} {/* After Image */} @@ -241,12 +262,18 @@ export default function MapfixDetailsPage() { transition: 'opacity 0.5s ease-in-out' }} > - + {thumbnailUrls[mapfix.AssetID] ? ( + + ) : ( + + + + )} (null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); + const [thumbnailUrls, setThumbnailUrls] = useState>({}); const cardsPerPage = 24; useEffect(() => { @@ -55,6 +56,21 @@ export default function MapfixInfoPage() { return () => controller.abort(); }, [currentPage]); + useEffect(() => { + if (!mapfixes) { + setThumbnailUrls({}); + return; + } + const ids = mapfixes.Mapfixes.map(m => m.AssetID).filter(Boolean).join(","); + if (!ids) { + setThumbnailUrls({}); + return; + } + fetch(`/thumbnails/batch?ids=${ids}`) + .then(res => res.json()) + .then(data => setThumbnailUrls(data)); + }, [mapfixes]); + if (isLoading || !mapfixes) { return ( @@ -117,6 +133,7 @@ export default function MapfixInfoPage() { gameID={mapfix.GameID} created={mapfix.CreatedAt} type="mapfix" + thumbnailUrl={thumbnailUrls[mapfix.AssetID]} /> ))} diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index 90c66e5..e00aa72 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -6,6 +6,7 @@ import { useParams, useRouter } from "next/navigation"; import React, { useState, useEffect } from "react"; import Link from "next/link"; import { Snackbar, Alert } from "@mui/material"; +import Image from "next/image"; // MUI Components import { @@ -22,7 +23,8 @@ import { Stack, CardMedia, Tooltip, - IconButton + IconButton, + CircularProgress } from "@mui/material"; import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; @@ -44,6 +46,7 @@ export default function MapDetails() { const [copySuccess, setCopySuccess] = useState(false); const [roles, setRoles] = useState(RolesConstants.Empty); const [downloading, setDownloading] = useState(false); + const [thumbnailUrl, setThumbnailUrl] = useState(null); useTitle(map ? `${map.DisplayName}` : 'Loading Map...'); @@ -87,6 +90,16 @@ export default function MapDetails() { getRoles() }, [mapId]); + useEffect(() => { + if (!map?.ID) { + setThumbnailUrl(null); + return; + } + fetch(`/thumbnails/batch?ids=${map.ID}`) + .then(res => res.json()) + .then(data => setThumbnailUrl(data[map.ID] || null)); + }, [map?.ID]); + const formatDate = (timestamp: number) => { return new Date(timestamp * 1000).toLocaleDateString('en-US', { year: 'numeric', @@ -314,15 +327,21 @@ export default function MapDetails() { position: 'relative' }} > - + {thumbnailUrl ? ( + + ) : ( + + + + )} diff --git a/web/src/app/maps/page.tsx b/web/src/app/maps/page.tsx index 1a63514..88ea19f 100644 --- a/web/src/app/maps/page.tsx +++ b/web/src/app/maps/page.tsx @@ -27,7 +27,6 @@ import {Search as SearchIcon} from "@mui/icons-material"; import Link from "next/link"; import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import {useTitle} from "@/app/hooks/useTitle"; -import {thumbnailLoader} from '@/app/lib/thumbnailLoader'; interface Map { ID: number; @@ -48,6 +47,7 @@ export default function MapsPage() { const [gameFilter, setGameFilter] = useState("0"); // 0 means "All Maps" const mapsPerPage = 12; const requestPageSize = 100; + const [thumbnails, setThumbnails] = useState>({}); useEffect(() => { const fetchMaps = async () => { @@ -77,11 +77,6 @@ export default function MapsPage() { fetchMaps(); }, []); - const handleGameFilterChange = (event: SelectChangeEvent) => { - setGameFilter(event.target.value); - setCurrentPage(1); - }; - // Filter maps based on search query and game filter const filteredMaps = maps.filter(map => { const matchesSearch = @@ -101,6 +96,23 @@ export default function MapsPage() { (currentPage - 1) * mapsPerPage, currentPage * mapsPerPage ); + const currentMapIds = currentMaps.map(m => m.ID).join(','); + + // Fetch thumbnails for current page + useEffect(() => { + if (!currentMapIds) { + setThumbnails({}); + return; + } + fetch(`/thumbnails/batch?ids=${currentMapIds}`) + .then((res) => res.json()) + .then((data) => setThumbnails(data)); + }, [currentMapIds]); + + const handleGameFilterChange = (event: SelectChangeEvent) => { + setGameFilter(event.target.value); + setCurrentPage(1); + }; const handlePageChange = (_event: React.ChangeEvent, page: number) => { setCurrentPage(page); @@ -262,13 +274,19 @@ export default function MapsPage() { > {getGameName(map.GameID)} - {map.DisplayName} + {thumbnails[map.ID] ? ( + {map.DisplayName} + ) : ( + + + + )} diff --git a/web/src/app/submissions/[submissionId]/page.tsx b/web/src/app/submissions/[submissionId]/page.tsx index 5654f7f..fbbaea5 100644 --- a/web/src/app/submissions/[submissionId]/page.tsx +++ b/web/src/app/submissions/[submissionId]/page.tsx @@ -1,7 +1,7 @@ "use client"; import Webpage from "@/app/_components/webpage"; import { useParams, useRouter } from "next/navigation"; -import {useState} from "react"; +import {useEffect, useState} from "react"; import Link from "next/link"; // MUI Components @@ -14,6 +14,7 @@ import { Skeleton, Grid, CardMedia, + CircularProgress, Snackbar, Alert, } from "@mui/material"; @@ -42,6 +43,7 @@ export default function SubmissionDetailsPage() { message: null, severity: 'success' }); + const [thumbnailUrl, setThumbnailUrl] = useState(null); const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => { setSnackbar({ open: true, @@ -76,6 +78,17 @@ export default function SubmissionDetailsPage() { useTitle(submission ? `${submission.DisplayName} Submission` : 'Loading Submission...'); + // Fetch thumbnail URL for the submission's AssetID + useEffect(() => { + if (!submission?.AssetID) { + setThumbnailUrl(null); + return; + } + fetch(`/thumbnails/batch?ids=${submission.AssetID}`) + .then(res => res.json()) + .then(data => setThumbnailUrl(data[submission.AssetID] || null)); + }, [submission?.AssetID]); + // Handle review button actions async function handleReviewAction(action: string, submissionId: number) { try { @@ -204,12 +217,27 @@ export default function SubmissionDetailsPage() { {submission.AssetID ? ( - + thumbnailUrl ? ( + + ) : ( + + + + ) ) : ( (null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); + const [thumbnailUrls, setThumbnailUrls] = useState>({}); const cardsPerPage = 24; useEffect(() => { @@ -55,6 +56,21 @@ export default function SubmissionInfoPage() { return () => controller.abort(); }, [currentPage]); + useEffect(() => { + if (!submissions) { + setThumbnailUrls({}); + return; + } + const ids = submissions.Submissions.map(s => s.AssetID).filter(Boolean).join(","); + if (!ids) { + setThumbnailUrls({}); + return; + } + fetch(`/thumbnails/batch?ids=${ids}`) + .then(res => res.json()) + .then(data => setThumbnailUrls(data)); + }, [submissions]); + if (isLoading || !submissions) { return ( @@ -129,6 +145,7 @@ export default function SubmissionInfoPage() { gameID={submission.GameID} created={submission.CreatedAt} type="submission" + thumbnailUrl={thumbnailUrls[submission.AssetID]} /> ))} diff --git a/web/src/app/thumbnails/asset/[assetId]/route.tsx b/web/src/app/thumbnails/asset/[assetId]/route.tsx index 0c76487..56e2fc5 100644 --- a/web/src/app/thumbnails/asset/[assetId]/route.tsx +++ b/web/src/app/thumbnails/asset/[assetId]/route.tsx @@ -1,55 +1,129 @@ import { NextRequest, NextResponse } from 'next/server'; import { errorImageResponse } from '@/app/lib/errorImageResponse'; +const imageCache = new Map(); +const CACHE_TTL = 6 * 60 * 60 * 1000; +const CLEANUP_INTERVAL = 60 * 60 * 1000; + +interface RobloxThumbnailData { + targetId: number; + imageUrl?: string; + [key: string]: unknown; +} +interface BatchRequest { + assetId: number; + resolve: (url: string | null) => void; + reject: (err: unknown) => void; +} +let batchQueue: BatchRequest[] = []; +let batchTimer: NodeJS.Timeout | null = null; +const BATCH_SIZE = 50; +const BATCH_WINDOW = 200; + +async function processBatch() { + const queue = batchQueue; + batchQueue = []; + batchTimer = null; + if (queue.length === 0) return; + const assetIds = Array.from(new Set(queue.map(r => r.assetId))); + const idToRequests = new Map(); + for (const req of queue) { + if (!idToRequests.has(req.assetId)) idToRequests.set(req.assetId, []); + idToRequests.get(req.assetId)!.push(req); + } + for (let i = 0; i < assetIds.length; i += BATCH_SIZE) { + const batchIds = assetIds.slice(i, i + BATCH_SIZE); + try { + const response = await fetch( + `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${batchIds.join(',')}` + ); + console.log(`https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${batchIds.join(',')}`) + if (!response.ok) throw new Error(`Failed to fetch thumbnail JSON [${response.status}]`); + const data = await response.json(); + for (const asset of batchIds) { + const found = (data.data as RobloxThumbnailData[]).find((d) => String(d.targetId) === String(asset)); + const imageUrl = found?.imageUrl || null; + if (imageUrl) imageCache.set(asset, { url: imageUrl, expires: Date.now() + CACHE_TTL }); + for (const req of idToRequests.get(asset) || []) { + if (imageUrl) req.resolve(imageUrl); + else req.reject('No image URL found'); + } + } + } catch (err) { + for (const asset of batchIds) { + for (const req of idToRequests.get(asset) || []) { + req.reject(err); + } + } + } + } +} + +declare global { + // eslint-disable-next-line no-var + var _thumbnailCacheCleanupStarted: boolean | undefined; +} + +if (typeof global !== 'undefined' && !global._thumbnailCacheCleanupStarted) { + setInterval(() => { + const now = Date.now(); + for (const [key, value] of imageCache.entries()) { + if (value.expires < now) { + imageCache.delete(key); + } + } + }, CLEANUP_INTERVAL); + global._thumbnailCacheCleanupStarted = true; +} + export async function GET( - request: NextRequest, - context: { params: Promise<{ assetId: number }> } + request: NextRequest, + context: { params: Promise<{ assetId: number }> } ): Promise { - const { assetId } = await context.params; + const { assetId } = await context.params; - if (!assetId) { - return errorImageResponse(400, { - message: "Missing asset ID", - }) - } + if (!assetId) { + return errorImageResponse(400, { + message: "Missing asset ID", + }); + } - let finalAssetId = assetId; + let finalAssetId = assetId; - try { - const mediaResponse = await fetch( - `https://publish.roblox.com/v1/assets/${assetId}/media` // NOTE: This allows users to add custom images(their own thumbnail if they'd like) to their maps - ); - if (mediaResponse.ok) { - const mediaData = await mediaResponse.json(); - if (mediaData.data && mediaData.data.length > 0) { - finalAssetId = mediaData.data[0].toString(); - } - } - } catch { } + const cached = imageCache.get(assetId); + const now = Date.now(); + if (cached && cached.expires > now) { + return NextResponse.redirect(cached.url); + } - try { - const response = await fetch( - `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${finalAssetId}` - ); + try { + const mediaResponse = await fetch( + `https://publish.roblox.com/v1/assets/${assetId}/media` + ); + if (mediaResponse.ok) { + const mediaData = await mediaResponse.json(); + if (mediaData.data && mediaData.data.length > 0) { + finalAssetId = mediaData.data[0].toString(); + } + } + } catch {} - if (!response.ok) { - throw new Error(`Failed to fetch thumbnail JSON [${response.status}]`) - } + const imageUrl = await new Promise((resolve, reject) => { + batchQueue.push({ assetId: Number(finalAssetId), resolve, reject }); + if (batchQueue.length >= BATCH_SIZE) { + if (batchTimer) clearTimeout(batchTimer); + processBatch(); + } else if (!batchTimer) { + batchTimer = setTimeout(processBatch, BATCH_WINDOW); + } + }); - const data = await response.json(); + if (!imageUrl) { + return errorImageResponse(404, { + message: "No image URL found in the response", + }); + } - const imageUrl = data.data[0]?.imageUrl; - if (!imageUrl) { - return errorImageResponse(404, { - message: "No image URL found in the response", - }) - } - - // Redirect to the actual image URL instead of proxying - return NextResponse.redirect(imageUrl); - } catch (err) { - return errorImageResponse(500, { - message: `Failed to fetch thumbnail URL: ${err}`, - }) - } + imageCache.set(assetId, { url: imageUrl, expires: now + CACHE_TTL }); + return NextResponse.redirect(imageUrl); } \ No newline at end of file -- 2.49.1 From f0abb9ffbf3d706b672642bad3c90243a04dd29b Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Mon, 30 Jun 2025 03:24:04 -0600 Subject: [PATCH 02/12] severe vibe coding going on here... on another note, build succeeds :D (i love eslint-disable >:)) <- is this even safe? we'll find out if the server explodes --- web/src/app/mapfixes/page.tsx | 18 +- web/src/app/maps/[mapId]/page.tsx | 1 - web/src/app/maps/page.tsx | 16 +- web/src/app/page.tsx | 8 + web/src/app/submissions/page.tsx | 18 +- .../app/thumbnails/asset/[assetId]/route.tsx | 191 +++++++----------- web/src/app/thumbnails/maps/[mapId]/route.tsx | 2 +- 7 files changed, 94 insertions(+), 160 deletions(-) diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx index 5b1f994..f2136e7 100644 --- a/web/src/app/mapfixes/page.tsx +++ b/web/src/app/mapfixes/page.tsx @@ -16,6 +16,7 @@ import { import Link from "next/link"; import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import {useTitle} from "@/app/hooks/useTitle"; +import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; export default function MapfixInfoPage() { useTitle("Map Fixes"); @@ -23,7 +24,6 @@ export default function MapfixInfoPage() { const [mapfixes, setMapfixes] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); - const [thumbnailUrls, setThumbnailUrls] = useState>({}); const cardsPerPage = 24; useEffect(() => { @@ -56,20 +56,8 @@ export default function MapfixInfoPage() { return () => controller.abort(); }, [currentPage]); - useEffect(() => { - if (!mapfixes) { - setThumbnailUrls({}); - return; - } - const ids = mapfixes.Mapfixes.map(m => m.AssetID).filter(Boolean).join(","); - if (!ids) { - setThumbnailUrls({}); - return; - } - fetch(`/thumbnails/batch?ids=${ids}`) - .then(res => res.json()) - .then(data => setThumbnailUrls(data)); - }, [mapfixes]); + const assetIds = mapfixes?.Mapfixes.map(m => m.AssetID) ?? []; + const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds); if (isLoading || !mapfixes) { return ( diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index e00aa72..8c346bc 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -6,7 +6,6 @@ import { useParams, useRouter } from "next/navigation"; import React, { useState, useEffect } from "react"; import Link from "next/link"; import { Snackbar, Alert } from "@mui/material"; -import Image from "next/image"; // MUI Components import { diff --git a/web/src/app/maps/page.tsx b/web/src/app/maps/page.tsx index 88ea19f..d20a65f 100644 --- a/web/src/app/maps/page.tsx +++ b/web/src/app/maps/page.tsx @@ -27,6 +27,7 @@ import {Search as SearchIcon} from "@mui/icons-material"; import Link from "next/link"; import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import {useTitle} from "@/app/hooks/useTitle"; +import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; interface Map { ID: number; @@ -47,7 +48,6 @@ export default function MapsPage() { const [gameFilter, setGameFilter] = useState("0"); // 0 means "All Maps" const mapsPerPage = 12; const requestPageSize = 100; - const [thumbnails, setThumbnails] = useState>({}); useEffect(() => { const fetchMaps = async () => { @@ -96,18 +96,8 @@ export default function MapsPage() { (currentPage - 1) * mapsPerPage, currentPage * mapsPerPage ); - const currentMapIds = currentMaps.map(m => m.ID).join(','); - - // Fetch thumbnails for current page - useEffect(() => { - if (!currentMapIds) { - setThumbnails({}); - return; - } - fetch(`/thumbnails/batch?ids=${currentMapIds}`) - .then((res) => res.json()) - .then((data) => setThumbnails(data)); - }, [currentMapIds]); + const currentMapIdsArr = currentMaps.map(m => m.ID); + const { thumbnails } = useBatchThumbnails(currentMapIdsArr); const handleGameFilterChange = (event: SelectChangeEvent) => { setGameFilter(event.target.value); diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 0ad41a6..f31f0ff 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -16,6 +16,7 @@ import Link from "next/link"; import {SubmissionInfo, SubmissionList} from "@/app/ts/Submission"; import {Carousel} from "@/app/_components/carousel"; import {useTitle} from "@/app/hooks/useTitle"; +import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; export default function Home() { useTitle("Home"); @@ -73,6 +74,11 @@ export default function Home() { }; }, []); + const submissionAssetIds = submissions?.Submissions.map(s => s.AssetID) ?? []; + const mapfixAssetIds = mapfixes?.Mapfixes.map(m => m.AssetID) ?? []; + const { thumbnails: submissionThumbnails } = useBatchThumbnails(submissionAssetIds); + const { thumbnails: mapfixThumbnails } = useBatchThumbnails(mapfixAssetIds); + const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions; if (isLoading && (!mapfixes || !submissions)) { @@ -108,6 +114,7 @@ export default function Home() { gameID={mapfix.GameID} created={mapfix.CreatedAt} type="mapfix" + thumbnailUrl={mapfixThumbnails[mapfix.AssetID]} /> ); @@ -124,6 +131,7 @@ export default function Home() { gameID={submission.GameID} created={submission.CreatedAt} type="submission" + thumbnailUrl={submissionThumbnails[submission.AssetID]} /> ); diff --git a/web/src/app/submissions/page.tsx b/web/src/app/submissions/page.tsx index db63970..a4b34b6 100644 --- a/web/src/app/submissions/page.tsx +++ b/web/src/app/submissions/page.tsx @@ -16,6 +16,7 @@ import { import Link from "next/link"; import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import {useTitle} from "@/app/hooks/useTitle"; +import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; export default function SubmissionInfoPage() { useTitle("Submissions"); @@ -23,7 +24,6 @@ export default function SubmissionInfoPage() { const [submissions, setSubmissions] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); - const [thumbnailUrls, setThumbnailUrls] = useState>({}); const cardsPerPage = 24; useEffect(() => { @@ -56,20 +56,8 @@ export default function SubmissionInfoPage() { return () => controller.abort(); }, [currentPage]); - useEffect(() => { - if (!submissions) { - setThumbnailUrls({}); - return; - } - const ids = submissions.Submissions.map(s => s.AssetID).filter(Boolean).join(","); - if (!ids) { - setThumbnailUrls({}); - return; - } - fetch(`/thumbnails/batch?ids=${ids}`) - .then(res => res.json()) - .then(data => setThumbnailUrls(data)); - }, [submissions]); + const assetIds = submissions?.Submissions.map(s => s.AssetID) ?? []; + const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds); if (isLoading || !submissions) { return ( diff --git a/web/src/app/thumbnails/asset/[assetId]/route.tsx b/web/src/app/thumbnails/asset/[assetId]/route.tsx index 56e2fc5..d9f53de 100644 --- a/web/src/app/thumbnails/asset/[assetId]/route.tsx +++ b/web/src/app/thumbnails/asset/[assetId]/route.tsx @@ -1,129 +1,90 @@ +/** + * @deprecated This endpoint is deprecated. Use /thumbnails/batch instead. This route will be removed in a future release. + */ + import { NextRequest, NextResponse } from 'next/server'; import { errorImageResponse } from '@/app/lib/errorImageResponse'; -const imageCache = new Map(); -const CACHE_TTL = 6 * 60 * 60 * 1000; -const CLEANUP_INTERVAL = 60 * 60 * 1000; - -interface RobloxThumbnailData { - targetId: number; - imageUrl?: string; - [key: string]: unknown; -} -interface BatchRequest { - assetId: number; - resolve: (url: string | null) => void; - reject: (err: unknown) => void; -} -let batchQueue: BatchRequest[] = []; -let batchTimer: NodeJS.Timeout | null = null; -const BATCH_SIZE = 50; -const BATCH_WINDOW = 200; - -async function processBatch() { - const queue = batchQueue; - batchQueue = []; - batchTimer = null; - if (queue.length === 0) return; - const assetIds = Array.from(new Set(queue.map(r => r.assetId))); - const idToRequests = new Map(); - for (const req of queue) { - if (!idToRequests.has(req.assetId)) idToRequests.set(req.assetId, []); - idToRequests.get(req.assetId)!.push(req); - } - for (let i = 0; i < assetIds.length; i += BATCH_SIZE) { - const batchIds = assetIds.slice(i, i + BATCH_SIZE); - try { - const response = await fetch( - `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${batchIds.join(',')}` - ); - console.log(`https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${batchIds.join(',')}`) - if (!response.ok) throw new Error(`Failed to fetch thumbnail JSON [${response.status}]`); - const data = await response.json(); - for (const asset of batchIds) { - const found = (data.data as RobloxThumbnailData[]).find((d) => String(d.targetId) === String(asset)); - const imageUrl = found?.imageUrl || null; - if (imageUrl) imageCache.set(asset, { url: imageUrl, expires: Date.now() + CACHE_TTL }); - for (const req of idToRequests.get(asset) || []) { - if (imageUrl) req.resolve(imageUrl); - else req.reject('No image URL found'); - } - } - } catch (err) { - for (const asset of batchIds) { - for (const req of idToRequests.get(asset) || []) { - req.reject(err); - } - } - } - } -} - -declare global { - // eslint-disable-next-line no-var - var _thumbnailCacheCleanupStarted: boolean | undefined; -} - -if (typeof global !== 'undefined' && !global._thumbnailCacheCleanupStarted) { - setInterval(() => { - const now = Date.now(); - for (const [key, value] of imageCache.entries()) { - if (value.expires < now) { - imageCache.delete(key); - } - } - }, CLEANUP_INTERVAL); - global._thumbnailCacheCleanupStarted = true; -} +const cache = new Map(); +const CACHE_TTL = 15 * 60 * 1000; export async function GET( - request: NextRequest, - context: { params: Promise<{ assetId: number }> } + request: NextRequest, + context: { params: Promise<{ assetId: number }> } ): Promise { - const { assetId } = await context.params; + const { assetId } = await context.params; + + if (!assetId) { + return errorImageResponse(400, { + message: "Missing asset ID", + }) + } - if (!assetId) { - return errorImageResponse(400, { - message: "Missing asset ID", - }); - } + let finalAssetId = assetId; - let finalAssetId = assetId; + try { + const mediaResponse = await fetch( + `https://publish.roblox.com/v1/assets/${assetId}/media` + ); + if (mediaResponse.ok) { + const mediaData = await mediaResponse.json(); + if (mediaData.data && mediaData.data.length > 0) { + finalAssetId = mediaData.data[0].toString(); + } + } + } catch { } - const cached = imageCache.get(assetId); - const now = Date.now(); - if (cached && cached.expires > now) { - return NextResponse.redirect(cached.url); - } + const now = Date.now(); + const cached = cache.get(finalAssetId); - try { - const mediaResponse = await fetch( - `https://publish.roblox.com/v1/assets/${assetId}/media` - ); - if (mediaResponse.ok) { - const mediaData = await mediaResponse.json(); - if (mediaData.data && mediaData.data.length > 0) { - finalAssetId = mediaData.data[0].toString(); - } - } - } catch {} + 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}`, + }, + }); + } - const imageUrl = await new Promise((resolve, reject) => { - batchQueue.push({ assetId: Number(finalAssetId), resolve, reject }); - if (batchQueue.length >= BATCH_SIZE) { - if (batchTimer) clearTimeout(batchTimer); - processBatch(); - } else if (!batchTimer) { - batchTimer = setTimeout(processBatch, BATCH_WINDOW); - } - }); + try { + const response = await fetch( + `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${finalAssetId}` + ); - if (!imageUrl) { - return errorImageResponse(404, { - message: "No image URL found in the response", - }); - } + if (!response.ok) { + throw new Error(`Failed to fetch thumbnail JSON [${response.status}]`) + } - imageCache.set(assetId, { url: imageUrl, expires: now + CACHE_TTL }); - return NextResponse.redirect(imageUrl); + const data = await response.json(); + + const imageUrl = data.data[0]?.imageUrl; + if (!imageUrl) { + return errorImageResponse(404, { + message: "No image URL found in the response", + }) + } + + const imageResponse = await fetch(imageUrl); + if (!imageResponse.ok) { + throw new Error(`Failed to fetch the image [${imageResponse.status}]`) + } + + const arrayBuffer = await imageResponse.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + cache.set(finalAssetId, { buffer, expires: now + CACHE_TTL }); + + return new NextResponse(buffer, { + headers: { + 'Content-Type': 'image/png', + 'Content-Length': buffer.length.toString(), + 'Cache-Control': `public, max-age=${CACHE_TTL / 1000}`, + }, + }); + } catch (err) { + return errorImageResponse(500, { + message: `Failed to fetch or process thumbnail: ${err}`, + }) + } } \ No newline at end of file diff --git a/web/src/app/thumbnails/maps/[mapId]/route.tsx b/web/src/app/thumbnails/maps/[mapId]/route.tsx index a6b21d3..13a0dd4 100644 --- a/web/src/app/thumbnails/maps/[mapId]/route.tsx +++ b/web/src/app/thumbnails/maps/[mapId]/route.tsx @@ -13,7 +13,7 @@ export async function GET( const { mapId } = await context.params const apiHost = process.env.API_HOST.replace(/\/api\/?$/, "") - const redirectPath = `/thumbnails/asset/${mapId}` + const redirectPath = `/thumbnails/asset/${mapId}` // The current date is Monday, June 30, 2025, 05:19:04 am. Let's see how long this line of code stays here (deprecated/waiting for thumbnails) const redirectUrl = `${apiHost}${redirectPath}` return NextResponse.redirect(redirectUrl) -- 2.49.1 From c21afaa84607465f89f0eb1cbd6080de7fa478ad Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Mon, 30 Jun 2025 04:03:34 -0600 Subject: [PATCH 03/12] why were these untracked... severely vibe coded files btw --- web/src/app/hooks/useBatchThumbnails.ts | 41 +++++++++++++++++++++ web/src/app/thumbnails/batch/route.ts | 47 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 web/src/app/hooks/useBatchThumbnails.ts create mode 100644 web/src/app/thumbnails/batch/route.ts diff --git a/web/src/app/hooks/useBatchThumbnails.ts b/web/src/app/hooks/useBatchThumbnails.ts new file mode 100644 index 0000000..9392c5a --- /dev/null +++ b/web/src/app/hooks/useBatchThumbnails.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from "react"; + +/** + * Fetches thumbnail URLs for a batch of asset IDs using the /thumbnails/batch endpoint. + * Handles loading and error state. Returns a mapping of assetId to URL. + */ +export function useBatchThumbnails(assetIds: (number | string)[] | undefined) { + const [thumbnails, setThumbnails] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const joinedIds = (assetIds && assetIds.filter(Boolean).join(",")) || ""; + + useEffect(() => { + if (!assetIds || assetIds.length === 0) { + setThumbnails({}); + setLoading(false); + setError(null); + return; + } + if (!joinedIds) { + setThumbnails({}); + setLoading(false); + setError(null); + return; + } + setLoading(true); + setError(null); + fetch(`/thumbnails/batch?ids=${joinedIds}`) + .then(res => { + if (!res.ok) throw new Error(`Failed to fetch thumbnails: ${res.status}`); + return res.json(); + }) + .then(data => setThumbnails(data)) + .catch(err => setError(err)) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [joinedIds]); // Only depend on joinedIds! + + return { thumbnails, loading, error }; +} diff --git a/web/src/app/thumbnails/batch/route.ts b/web/src/app/thumbnails/batch/route.ts new file mode 100644 index 0000000..7ecba7c --- /dev/null +++ b/web/src/app/thumbnails/batch/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const CACHE_TTL = 6 * 60 * 60 * 1000; +const imageCache = new Map(); + +interface RobloxThumbnailData { + targetId: number; + imageUrl?: string; +} + +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const idsParam = url.searchParams.get('ids'); + if (!idsParam) { + return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 }); + } + const ids = idsParam.split(',').map(Number).filter(Boolean); + const now = Date.now(); + const result: Record = {}; + const idsToFetch: number[] = []; + + for (const id of ids) { + const cached = imageCache.get(id); + if (cached && cached.expires > now) { + result[id] = cached.url; + } else { + idsToFetch.push(id); + } + } + + for (let i = 0; i < idsToFetch.length; i += 50) { + const batch = idsToFetch.slice(i, i + 50); + const response = await fetch( + `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${batch.join(',')}` + ); + if (!response.ok) continue; + const data = await response.json(); + for (const id of batch) { + const found = (data.data as RobloxThumbnailData[]).find(d => String(d.targetId) === String(id)); + const imageUrl = found?.imageUrl || null; + if (imageUrl) imageCache.set(id, { url: imageUrl, expires: now + CACHE_TTL }); + result[id] = imageUrl; + } + } + + return NextResponse.json(result); +} -- 2.49.1 From a1e0e5f7206c568532673c09b05ffa30ae2569e1 Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Mon, 30 Jun 2025 16:47:19 -0600 Subject: [PATCH 04/12] Subtle visual changes, user avatar batching, rate-limiting, submitter name instead of author, ai comments --- .../_components/comments/AuditEventItem.tsx | 5 +- .../app/_components/comments/CommentItem.tsx | 5 +- .../comments/CommentsAndAuditSection.tsx | 6 ++ .../_components/comments/CommentsTabPanel.tsx | 13 +++- web/src/app/_components/mapCard.tsx | 64 ++++++++-------- web/src/app/_components/review/ReviewItem.tsx | 3 + .../_components/review/ReviewItemHeader.tsx | 7 +- web/src/app/hooks/useBatchUserAvatars.ts | 41 ++++++++++ web/src/app/mapfixes/page.tsx | 34 ++++++++- web/src/app/page.tsx | 65 +++++++++++++++- web/src/app/proxy/users/[userId]/route.ts | 14 ++++ web/src/app/submissions/page.tsx | 34 ++++++++- web/src/app/thumbnails/batch/route.ts | 14 ++++ .../app/thumbnails/user/[userId]/route.tsx | 30 +++++++- web/src/app/thumbnails/user/batch/route.ts | 74 +++++++++++++++++++ web/src/lib/rateLimit.ts | 32 ++++++++ 16 files changed, 394 insertions(+), 47 deletions(-) create mode 100644 web/src/app/hooks/useBatchUserAvatars.ts create mode 100644 web/src/app/thumbnails/user/batch/route.ts create mode 100644 web/src/lib/rateLimit.ts diff --git a/web/src/app/_components/comments/AuditEventItem.tsx b/web/src/app/_components/comments/AuditEventItem.tsx index f4a2989..fbf79d5 100644 --- a/web/src/app/_components/comments/AuditEventItem.tsx +++ b/web/src/app/_components/comments/AuditEventItem.tsx @@ -12,13 +12,14 @@ import { AuditEvent, decodeAuditEvent as auditEventMessage } from "@/app/ts/Audi interface AuditEventItemProps { event: AuditEvent; validatorUser: number; + userAvatarUrl?: string; } -export default function AuditEventItem({ event, validatorUser }: AuditEventItemProps) { +export default function AuditEventItem({ event, validatorUser, userAvatarUrl }: AuditEventItemProps) { return ( diff --git a/web/src/app/_components/comments/CommentItem.tsx b/web/src/app/_components/comments/CommentItem.tsx index a385244..931724f 100644 --- a/web/src/app/_components/comments/CommentItem.tsx +++ b/web/src/app/_components/comments/CommentItem.tsx @@ -12,13 +12,14 @@ import { AuditEvent, decodeAuditEvent } from "@/app/ts/AuditEvent"; interface CommentItemProps { event: AuditEvent; validatorUser: number; + userAvatarUrl?: string; } -export default function CommentItem({ event, validatorUser }: CommentItemProps) { +export default function CommentItem({ event, validatorUser, userAvatarUrl }: CommentItemProps) { return ( diff --git a/web/src/app/_components/comments/CommentsAndAuditSection.tsx b/web/src/app/_components/comments/CommentsAndAuditSection.tsx index 274304b..2f1b9ad 100644 --- a/web/src/app/_components/comments/CommentsAndAuditSection.tsx +++ b/web/src/app/_components/comments/CommentsAndAuditSection.tsx @@ -8,6 +8,7 @@ import { import CommentsTabPanel from './CommentsTabPanel'; import AuditEventsTabPanel from './AuditEventsTabPanel'; import { AuditEvent } from "@/app/ts/AuditEvent"; +import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; interface CommentsAndAuditSectionProps { auditEvents: AuditEvent[]; @@ -32,6 +33,10 @@ export default function CommentsAndAuditSection({ setActiveTab(newValue); }; + // Collect all unique user IDs from auditEvents for avatar fetching + const commentUserIds = Array.from(new Set(auditEvents.map(ev => ev.User))); + const { avatars: commentUserAvatarUrls } = useBatchUserAvatars(commentUserIds); + return ( @@ -53,6 +58,7 @@ export default function CommentsAndAuditSection({ setNewComment={setNewComment} handleCommentSubmit={handleCommentSubmit} userId={userId} + commentUserAvatarUrls={commentUserAvatarUrls} /> void; handleCommentSubmit: () => void; userId: number | null; + userAvatarUrl?: string; + commentUserAvatarUrls?: Record; } export default function CommentsTabPanel({ @@ -27,7 +29,9 @@ export default function CommentsTabPanel({ newComment, setNewComment, handleCommentSubmit, - userId + userId, + userAvatarUrl, + commentUserAvatarUrls }: CommentsTabPanelProps) { const commentEvents = auditEvents.filter( event => event.EventType === AuditEventType.Comment @@ -44,6 +48,7 @@ export default function CommentsTabPanel({ key={index} event={event} validatorUser={validatorUser} + userAvatarUrl={commentUserAvatarUrls?.[event.User]} /> )) ) : ( @@ -59,6 +64,7 @@ export default function CommentsTabPanel({ setNewComment={setNewComment} handleCommentSubmit={handleCommentSubmit} userId={userId} + userAvatarUrl={userAvatarUrl} /> )} @@ -72,13 +78,14 @@ interface CommentInputProps { setNewComment: (comment: string) => void; handleCommentSubmit: () => void; userId: number | null; + userAvatarUrl?: string; } -function CommentInput({ newComment, setNewComment, handleCommentSubmit, userId }: CommentInputProps) { +function CommentInput({ newComment, setNewComment, handleCommentSubmit, userId, userAvatarUrl }: CommentInputProps) { return ( - - - - - - {/*In the future author should be the username of the submitter not the info from the map*/} - {props.author} - {new Date(props.created * 1000).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - })} - - + + + + + {props.submitterUsername} - {new Date(props.created * 1000).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} + diff --git a/web/src/app/_components/review/ReviewItem.tsx b/web/src/app/_components/review/ReviewItem.tsx index 1b531e7..cbf1a83 100644 --- a/web/src/app/_components/review/ReviewItem.tsx +++ b/web/src/app/_components/review/ReviewItem.tsx @@ -3,6 +3,7 @@ import { ReviewItemHeader } from "./ReviewItemHeader"; import { CopyableField } from "@/app/_components/review/CopyableField"; import { SubmissionInfo } from "@/app/ts/Submission"; import { MapfixInfo } from "@/app/ts/Mapfix"; +import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; // Define a field configuration for specific types interface FieldConfig { @@ -23,6 +24,7 @@ export function ReviewItem({ handleCopyValue }: ReviewItemProps) { // Type guard to check if item is valid + const { avatars: submitterAvatars } = useBatchUserAvatars(item ? [item.Submitter] : []); if (!item) return null; // Determine the type of item @@ -52,6 +54,7 @@ export function ReviewItem({ statusId={item.StatusID} creator={item.Creator} submitterId={item.Submitter} + submitterAvatarUrl={submitterAvatars[item.Submitter]} /> {/* Item Details */} diff --git a/web/src/app/_components/review/ReviewItemHeader.tsx b/web/src/app/_components/review/ReviewItemHeader.tsx index 0edefef..7c0b1c2 100644 --- a/web/src/app/_components/review/ReviewItemHeader.tsx +++ b/web/src/app/_components/review/ReviewItemHeader.tsx @@ -45,9 +45,10 @@ interface ReviewItemHeaderProps { statusId: SubmissionStatus | MapfixStatus; creator: string | null | undefined; submitterId: number; + submitterAvatarUrl?: string; } -export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId }: ReviewItemHeaderProps) => { +export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId, submitterAvatarUrl }: ReviewItemHeaderProps) => { const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]); const pulse = keyframes` 0%, 100% { opacity: 0.2; transform: scale(0.8); } @@ -90,8 +91,8 @@ export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId } diff --git a/web/src/app/hooks/useBatchUserAvatars.ts b/web/src/app/hooks/useBatchUserAvatars.ts new file mode 100644 index 0000000..4186718 --- /dev/null +++ b/web/src/app/hooks/useBatchUserAvatars.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from "react"; + +/** + * Fetches avatar URLs for a batch of user IDs using the /thumbnails/user/batch endpoint. + * Returns a mapping of userId to avatar URL. + */ +export function useBatchUserAvatars(userIds: (number | string)[] | undefined) { + const [avatars, setAvatars] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const joinedIds = (userIds && userIds.filter(Boolean).join(",")) || ""; + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (!userIds || userIds.length === 0) { + setAvatars({}); + setLoading(false); + setError(null); + return; + } + if (!joinedIds) { + setAvatars({}); + setLoading(false); + setError(null); + return; + } + setLoading(true); + setError(null); + fetch(`/thumbnails/user/batch?ids=${joinedIds}`) + .then(res => { + if (!res.ok) throw new Error(`Failed to fetch user avatars: ${res.status}`); + return res.json(); + }) + .then(data => setAvatars(data)) + .catch(err => setError(err)) + .finally(() => setLoading(false)); + }, [joinedIds]); + + return { avatars, loading, error }; +} diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx index f2136e7..d8afe76 100644 --- a/web/src/app/mapfixes/page.tsx +++ b/web/src/app/mapfixes/page.tsx @@ -17,6 +17,7 @@ import Link from "next/link"; import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import {useTitle} from "@/app/hooks/useTitle"; import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; +import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; export default function MapfixInfoPage() { useTitle("Map Fixes"); @@ -24,6 +25,7 @@ export default function MapfixInfoPage() { const [mapfixes, setMapfixes] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); + const [submitterUsernames, setSubmitterUsernames] = useState>({}); const cardsPerPage = 24; useEffect(() => { @@ -59,6 +61,34 @@ export default function MapfixInfoPage() { const assetIds = mapfixes?.Mapfixes.map(m => m.AssetID) ?? []; const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds); + // Collect unique submitter IDs for avatar and username fetching + const submitterIds = mapfixes ? Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter))) : []; + const { avatars: avatarUrls } = useBatchUserAvatars(submitterIds); + + // Fetch submitter usernames + useEffect(() => { + if (!mapfixes) return; + const uniqueIds = Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter))); + const fetchUsernames = async () => { + const results: Record = {}; + await Promise.all(uniqueIds.map(async (id) => { + try { + const res = await fetch(`/proxy/users/${id}`); + if (res.ok) { + const data = await res.json(); + results[id] = data.name || String(id); + } else { + results[id] = String(id); + } + } catch { + results[id] = String(id); + } + })); + setSubmitterUsernames(results); + }; + fetchUsernames(); + }, [mapfixes]); + if (isLoading || !mapfixes) { return ( @@ -115,13 +145,15 @@ export default function MapfixInfoPage() { assetId={mapfix.AssetID} displayName={mapfix.DisplayName} author={mapfix.Creator} - authorId={mapfix.Submitter} + submitterId={mapfix.Submitter} + submitterUsername={submitterUsernames[mapfix.Submitter] || String(mapfix.Submitter)} rating={mapfix.StatusID} statusID={mapfix.StatusID} gameID={mapfix.GameID} created={mapfix.CreatedAt} type="mapfix" thumbnailUrl={thumbnailUrls[mapfix.AssetID]} + authorAvatarUrl={avatarUrls[mapfix.Submitter]} /> ))} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index f31f0ff..f62614b 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -17,6 +17,7 @@ import {SubmissionInfo, SubmissionList} from "@/app/ts/Submission"; import {Carousel} from "@/app/_components/carousel"; import {useTitle} from "@/app/hooks/useTitle"; import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; +import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; export default function Home() { useTitle("Home"); @@ -25,6 +26,8 @@ export default function Home() { const [submissions, setSubmissions] = useState(null); const [isLoadingMapfixes, setIsLoadingMapfixes] = useState(false); const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(false); + const [submissionUsernames, setSubmissionUsernames] = useState>({}); + const [mapfixUsernames, setMapfixUsernames] = useState>({}); const itemsPerSection: number = 8; // Show more items for the carousel useEffect(() => { @@ -79,6 +82,60 @@ export default function Home() { const { thumbnails: submissionThumbnails } = useBatchThumbnails(submissionAssetIds); const { thumbnails: mapfixThumbnails } = useBatchThumbnails(mapfixAssetIds); + // Collect unique submitter IDs for avatar and username fetching + const submissionAuthorIds = submissions ? Array.from(new Set(submissions.Submissions.map(s => s.Submitter))) : []; + const mapfixAuthorIds = mapfixes ? Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter))) : []; + const { avatars: submissionAvatars } = useBatchUserAvatars(submissionAuthorIds); + const { avatars: mapfixAvatars } = useBatchUserAvatars(mapfixAuthorIds); + + // Fetch submitter usernames for submissions + useEffect(() => { + if (!submissions) return; + const uniqueIds = Array.from(new Set(submissions.Submissions.map(s => s.Submitter))); + const fetchUsernames = async () => { + const results: Record = {}; + await Promise.all(uniqueIds.map(async (id) => { + try { + const res = await fetch(`/proxy/users/${id}`); + if (res.ok) { + const data = await res.json(); + results[id] = data.name || String(id); + } else { + results[id] = String(id); + } + } catch { + results[id] = String(id); + } + })); + setSubmissionUsernames(results); + }; + fetchUsernames(); + }, [submissions]); + + // Fetch submitter usernames for mapfixes + useEffect(() => { + if (!mapfixes) return; + const uniqueIds = Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter))); + const fetchUsernames = async () => { + const results: Record = {}; + await Promise.all(uniqueIds.map(async (id) => { + try { + const res = await fetch(`/proxy/users/${id}`); + if (res.ok) { + const data = await res.json(); + results[id] = data.name || String(id); + } else { + results[id] = String(id); + } + } catch { + results[id] = String(id); + } + })); + setMapfixUsernames(results); + }; + fetchUsernames(); + }, [mapfixes]); + const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions; if (isLoading && (!mapfixes || !submissions)) { @@ -108,13 +165,15 @@ export default function Home() { assetId={mapfix.AssetID} displayName={mapfix.DisplayName} author={mapfix.Creator} - authorId={mapfix.Submitter} + submitterId={mapfix.Submitter} + submitterUsername={mapfixUsernames[mapfix.Submitter] || String(mapfix.Submitter)} rating={mapfix.StatusID} statusID={mapfix.StatusID} gameID={mapfix.GameID} created={mapfix.CreatedAt} type="mapfix" thumbnailUrl={mapfixThumbnails[mapfix.AssetID]} + authorAvatarUrl={mapfixAvatars[mapfix.Submitter]} /> ); @@ -125,13 +184,15 @@ export default function Home() { assetId={submission.AssetID} displayName={submission.DisplayName} author={submission.Creator} - authorId={submission.Submitter} + submitterId={submission.Submitter} + submitterUsername={submissionUsernames[submission.Submitter] || String(submission.Submitter)} rating={submission.StatusID} statusID={submission.StatusID} gameID={submission.GameID} created={submission.CreatedAt} type="submission" thumbnailUrl={submissionThumbnails[submission.AssetID]} + authorAvatarUrl={submissionAvatars[submission.Submitter]} /> ); diff --git a/web/src/app/proxy/users/[userId]/route.ts b/web/src/app/proxy/users/[userId]/route.ts index b745db2..d8087af 100644 --- a/web/src/app/proxy/users/[userId]/route.ts +++ b/web/src/app/proxy/users/[userId]/route.ts @@ -1,9 +1,23 @@ +// NOTE: This API endpoint proxies Roblox user info and implements in-memory rate limiting. +// For production, this logic should be moved to a dedicated backend API server (not serverless/edge) +// to allow for robust, distributed rate limiting and to avoid leaking your Roblox API quota. +// +// If you are behind a CDN/proxy, ensure you trust the IP headers. +// +// Consider using Redis or another distributed store for rate limiting in production. + +import { checkRateLimit } from '@/lib/rateLimit'; import { NextResponse } from 'next/server'; export async function GET( request: Request, { params }: { params: Promise<{ userId: string }> } ) { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown'; + if (!checkRateLimit(ip)) { + return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); + } + const { userId } = await params; if (!userId) { diff --git a/web/src/app/submissions/page.tsx b/web/src/app/submissions/page.tsx index a4b34b6..6d58985 100644 --- a/web/src/app/submissions/page.tsx +++ b/web/src/app/submissions/page.tsx @@ -17,6 +17,7 @@ import Link from "next/link"; import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import {useTitle} from "@/app/hooks/useTitle"; import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; +import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; export default function SubmissionInfoPage() { useTitle("Submissions"); @@ -24,6 +25,7 @@ export default function SubmissionInfoPage() { const [submissions, setSubmissions] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); + const [submitterUsernames, setSubmitterUsernames] = useState>({}); const cardsPerPage = 24; useEffect(() => { @@ -59,6 +61,34 @@ export default function SubmissionInfoPage() { const assetIds = submissions?.Submissions.map(s => s.AssetID) ?? []; const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds); + // Collect submitter user IDs and fetch their avatars + const submitterIds = submissions?.Submissions.map(s => s.Submitter) ?? []; + const { avatars: submitterAvatars } = useBatchUserAvatars(submitterIds); + + // Fetch submitter usernames + useEffect(() => { + if (!submissions) return; + const uniqueIds = Array.from(new Set(submissions.Submissions.map(s => s.Submitter))); + const fetchUsernames = async () => { + const results: Record = {}; + await Promise.all(uniqueIds.map(async (id) => { + try { + const res = await fetch(`/proxy/users/${id}`); + if (res.ok) { + const data = await res.json(); + results[id] = data.name || String(id); + } else { + results[id] = String(id); + } + } catch { + results[id] = String(id); + } + })); + setSubmitterUsernames(results); + }; + fetchUsernames(); + }, [submissions]); + if (isLoading || !submissions) { return ( @@ -127,13 +157,15 @@ export default function SubmissionInfoPage() { assetId={submission.AssetID} displayName={submission.DisplayName} author={submission.Creator} - authorId={submission.Submitter} + submitterId={submission.Submitter} + submitterUsername={submitterUsernames[submission.Submitter] || String(submission.Submitter)} rating={submission.StatusID} statusID={submission.StatusID} gameID={submission.GameID} created={submission.CreatedAt} type="submission" thumbnailUrl={thumbnailUrls[submission.AssetID]} + authorAvatarUrl={submitterAvatars[submission.Submitter]} /> ))} diff --git a/web/src/app/thumbnails/batch/route.ts b/web/src/app/thumbnails/batch/route.ts index 7ecba7c..edb8bda 100644 --- a/web/src/app/thumbnails/batch/route.ts +++ b/web/src/app/thumbnails/batch/route.ts @@ -1,4 +1,13 @@ +// NOTE: This API endpoint proxies Roblox asset thumbnails and implements in-memory rate limiting. +// For production, this logic should be moved to a dedicated backend API server (not serverless/edge) +// to allow for robust, distributed rate limiting and to avoid leaking your Roblox API quota. +// +// If you are behind a CDN/proxy, ensure you trust the IP headers. +// +// Consider using Redis or another distributed store for rate limiting in production. + import { NextRequest, NextResponse } from 'next/server'; +import { checkRateLimit } from '@/lib/rateLimit'; const CACHE_TTL = 6 * 60 * 60 * 1000; const imageCache = new Map(); @@ -9,6 +18,11 @@ interface RobloxThumbnailData { } export async function GET(request: NextRequest) { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown'; + if (!checkRateLimit(ip)) { + return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); + } + const url = new URL(request.url); const idsParam = url.searchParams.get('ids'); if (!idsParam) { diff --git a/web/src/app/thumbnails/user/[userId]/route.tsx b/web/src/app/thumbnails/user/[userId]/route.tsx index 1e08966..af77ba8 100644 --- a/web/src/app/thumbnails/user/[userId]/route.tsx +++ b/web/src/app/thumbnails/user/[userId]/route.tsx @@ -1,5 +1,25 @@ +/** + * @deprecated This endpoint is deprecated. Use /thumbnails/batch instead. This route will be removed in a future release. + */ + import { NextRequest, NextResponse } from 'next/server'; +const CACHE_TTL = 6 * 60 * 60 * 1000; +const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000; +const cache = new Map(); + +// Periodic cleanup to prevent memory leaks +if (typeof globalThis.__userThumbCacheCleanup === 'undefined') { + globalThis.__userThumbCacheCleanup = setInterval(() => { + const now = Date.now(); + for (const [userId, entry] of cache.entries()) { + if (entry.expires <= now) { + cache.delete(userId); + } + } + }, CACHE_CLEANUP_INTERVAL); +} + export async function GET( request: NextRequest, context: { params: Promise<{ userId: number }> } @@ -13,6 +33,12 @@ export async function GET( ); } + const now = Date.now(); + const cached = cache.get(userId); + if (cached && cached.expires > now) { + return NextResponse.redirect(cached.url); + } + try { const response = await fetch( `https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${userId}&size=420x420&format=Png&isCircular=false` @@ -32,9 +58,11 @@ export async function GET( ); } + // Cache the result + cache.set(userId, { url: imageUrl, expires: now + CACHE_TTL }); + // Redirect to the image URL instead of proxying return NextResponse.redirect(imageUrl); - } catch { return NextResponse.json( { error: 'Failed to fetch avatar headshot URL' }, diff --git a/web/src/app/thumbnails/user/batch/route.ts b/web/src/app/thumbnails/user/batch/route.ts new file mode 100644 index 0000000..84242d5 --- /dev/null +++ b/web/src/app/thumbnails/user/batch/route.ts @@ -0,0 +1,74 @@ +// NOTE: This API endpoint proxies Roblox avatar thumbnails and implements in-memory rate limiting. +// For production, this logic should be moved to a dedicated backend API server (not serverless/edge) +// to allow for robust, distributed rate limiting and to avoid leaking your Roblox API quota. +// +// If you are behind a CDN/proxy, ensure you trust the IP headers. +// +// Consider using Redis or another distributed store for rate limiting in production. + +import { NextRequest, NextResponse } from 'next/server'; +import { checkRateLimit } from '@/lib/rateLimit'; + +const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours +const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour +const userImageCache = new Map(); + +// Periodic cleanup to prevent memory leaks +if (typeof globalThis.__userBatchThumbCacheCleanup === 'undefined') { + globalThis.__userBatchThumbCacheCleanup = setInterval(() => { + const now = Date.now(); + for (const [userId, entry] of userImageCache.entries()) { + if (entry.expires <= now) { + userImageCache.delete(userId); + } + } + }, CACHE_CLEANUP_INTERVAL); +} + +interface RobloxUserThumbnailData { + targetId: number; + imageUrl?: string; +} + +export async function GET(request: NextRequest) { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown'; + if (!checkRateLimit(ip)) { + return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); + } + + const url = new URL(request.url); + const idsParam = url.searchParams.get('ids'); + if (!idsParam) { + return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 }); + } + const ids = idsParam.split(',').map(Number).filter(Boolean); + const now = Date.now(); + const result: Record = {}; + const idsToFetch: number[] = []; + + for (const id of ids) { + const cached = userImageCache.get(id); + if (cached && cached.expires > now) { + result[id] = cached.url; + } else { + idsToFetch.push(id); + } + } + + for (let i = 0; i < idsToFetch.length; i += 50) { + const batch = idsToFetch.slice(i, i + 50); + const response = await fetch( + `https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${batch.join(',')}&size=420x420&format=Png&isCircular=false` + ); + if (!response.ok) continue; + const data = await response.json(); + for (const id of batch) { + const found = (data.data as RobloxUserThumbnailData[]).find(d => String(d.targetId) === String(id)); + const imageUrl = found?.imageUrl || null; + if (imageUrl) userImageCache.set(id, { url: imageUrl, expires: now + CACHE_TTL }); + result[id] = imageUrl; + } + } + + return NextResponse.json(result); +} diff --git a/web/src/lib/rateLimit.ts b/web/src/lib/rateLimit.ts new file mode 100644 index 0000000..56e1f4c --- /dev/null +++ b/web/src/lib/rateLimit.ts @@ -0,0 +1,32 @@ +// NOTE: This file is used as a shared in-memory per-IP rate limiter for all Next.js API routes that need it. +// Not for production-scale, but good for basic abuse prevention. +// +// For production, use a distributed store (e.g., Redis) and import this from a shared location. +const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute +const RATE_LIMIT_MAX = 20; // 20 requests per minute per IP + +// Map +const ipRateLimitMap = new Map(); + +export function checkRateLimit(ip: string): boolean { + const now = Date.now(); + const entry = ipRateLimitMap.get(ip); + if (!entry || entry.expires < now) { + ipRateLimitMap.set(ip, { count: 1, expires: now + RATE_LIMIT_WINDOW_MS }); + return true; + } + if (entry.count >= RATE_LIMIT_MAX) { + return false; + } + entry.count++; + return true; +} + +if (typeof globalThis.__ipRateLimitCleanup === 'undefined') { + globalThis.__ipRateLimitCleanup = setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of ipRateLimitMap.entries()) { + if (entry.expires < now) ipRateLimitMap.delete(ip); + } + }, RATE_LIMIT_WINDOW_MS); +} -- 2.49.1 From 7421e6d989e4c015a10e222feea0d7abda1b039c Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Mon, 30 Jun 2025 17:40:13 -0600 Subject: [PATCH 05/12] Batched user information requests, optimized requests (halved from 6 to 3 :money_mouth:), cleanup --- web/src/app/hooks/useBatchUsernames.ts | 49 ++++++++++++++++++++++++++ web/src/app/proxy/users/batch/route.ts | 42 ++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 web/src/app/hooks/useBatchUsernames.ts create mode 100644 web/src/app/proxy/users/batch/route.ts diff --git a/web/src/app/hooks/useBatchUsernames.ts b/web/src/app/hooks/useBatchUsernames.ts new file mode 100644 index 0000000..dd82d30 --- /dev/null +++ b/web/src/app/hooks/useBatchUsernames.ts @@ -0,0 +1,49 @@ +import { useState, useEffect } from "react"; + +/** + * Fetches usernames for a batch of user IDs using the /proxy/users/batch?ids=... endpoint. + * Returns a mapping of userId to username (or userId as string if not found). + */ +export function useBatchUsernames(userIds: (number | string)[] | undefined) { + const [usernames, setUsernames] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const joinedIds = (userIds && userIds.filter(Boolean).join(",")) || ""; + + useEffect(() => { + if (!userIds || userIds.length === 0) { + setUsernames({}); + setLoading(false); + setError(null); + return; + } + if (!joinedIds) { + setUsernames({}); + setLoading(false); + setError(null); + return; + } + setLoading(true); + setError(null); + fetch(`/proxy/users/batch?ids=${joinedIds}`) + .then(res => { + if (!res.ok) throw new Error(`Failed to fetch usernames: ${res.status}`); + return res.json(); + }) + .then(data => { + const result: Record = {}; + if (Array.isArray(data.data)) { + for (const user of data.data) { + result[user.id] = user.name || String(user.id); + } + } + setUsernames(result); + }) + .catch(err => setError(err)) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [joinedIds]); + + return { usernames, loading, error }; +} diff --git a/web/src/app/proxy/users/batch/route.ts b/web/src/app/proxy/users/batch/route.ts new file mode 100644 index 0000000..0e1186d --- /dev/null +++ b/web/src/app/proxy/users/batch/route.ts @@ -0,0 +1,42 @@ +// NOTE: This API endpoint proxies Roblox user info in batch and implements in-memory rate limiting. +// For production, this logic should be moved to a dedicated backend API server (not serverless/edge) +// to allow for robust, distributed rate limiting and to avoid leaking your Roblox API quota. +// +// If you are behind a CDN/proxy, ensure you trust the IP headers. +// Consider using Redis or another distributed store for rate limiting in production. + +import { checkRateLimit } from '@/lib/rateLimit'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const url = new URL(request.url); + const idsParam = url.searchParams.get('ids'); + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown'; + if (!checkRateLimit(ip)) { + return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); + } + if (!idsParam) { + return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 }); + } + const userIds = idsParam.split(',').map(Number).filter(Boolean); + if (userIds.length === 0) { + return NextResponse.json({ error: 'No valid user IDs provided' }, { status: 400 }); + } + try { + const apiResponse = await fetch('https://users.roblox.com/v1/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userIds }), + }); + if (!apiResponse.ok) { + const errorData = await apiResponse.text(); + return NextResponse.json({ error: `Failed to fetch from Roblox API: ${errorData}` }, { status: apiResponse.status }); + } + const data = await apiResponse.json(); + const headers = new Headers(); + headers.set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); + return NextResponse.json(data, { headers }); + } catch { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} -- 2.49.1 From 8d4d6b7bfe94346fea27beed8525cdf6003f6b6f Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Mon, 30 Jun 2025 17:40:42 -0600 Subject: [PATCH 06/12] forgot to click stage all: Batched user information requests, optimized requests (halved from 6 to 3 :money_mouth:), cleanup --- .../comments/CommentsAndAuditSection.tsx | 22 ++-- web/src/app/_components/review/ReviewItem.tsx | 16 +-- .../_components/review/ReviewItemHeader.tsx | 46 ++------ web/src/app/hooks/useBatchThumbnails.ts | 6 +- web/src/app/hooks/useBatchUserAvatars.ts | 5 +- web/src/app/mapfixes/[mapfixId]/page.tsx | 47 +++++--- web/src/app/mapfixes/page.tsx | 27 +---- web/src/app/maps/[mapId]/page.tsx | 18 ++-- web/src/app/page.tsx | 53 +--------- web/src/app/proxy/users/[userId]/route.ts | 45 -------- .../app/submissions/[submissionId]/page.tsx | 71 +++++++------ web/src/app/submissions/page.tsx | 27 +---- .../app/thumbnails/asset/[assetId]/route.tsx | 90 ---------------- web/src/app/thumbnails/batch/route.ts | 100 +++++++++++------- web/src/app/thumbnails/maps/[mapId]/route.tsx | 20 ---- .../app/thumbnails/user/[userId]/route.tsx | 72 ------------- web/src/app/thumbnails/user/batch/route.ts | 74 ------------- web/src/lib/rateLimit.ts | 4 +- 18 files changed, 182 insertions(+), 561 deletions(-) delete mode 100644 web/src/app/proxy/users/[userId]/route.ts delete mode 100644 web/src/app/thumbnails/asset/[assetId]/route.tsx delete mode 100644 web/src/app/thumbnails/maps/[mapId]/route.tsx delete mode 100644 web/src/app/thumbnails/user/[userId]/route.tsx delete mode 100644 web/src/app/thumbnails/user/batch/route.ts diff --git a/web/src/app/_components/comments/CommentsAndAuditSection.tsx b/web/src/app/_components/comments/CommentsAndAuditSection.tsx index 2f1b9ad..f0f4074 100644 --- a/web/src/app/_components/comments/CommentsAndAuditSection.tsx +++ b/web/src/app/_components/comments/CommentsAndAuditSection.tsx @@ -8,7 +8,6 @@ import { import CommentsTabPanel from './CommentsTabPanel'; import AuditEventsTabPanel from './AuditEventsTabPanel'; import { AuditEvent } from "@/app/ts/AuditEvent"; -import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; interface CommentsAndAuditSectionProps { auditEvents: AuditEvent[]; @@ -17,26 +16,23 @@ interface CommentsAndAuditSectionProps { handleCommentSubmit: () => void; validatorUser: number; userId: number | null; + commentUserAvatarUrls: Record; } export default function CommentsAndAuditSection({ - auditEvents, - newComment, - setNewComment, - handleCommentSubmit, - validatorUser, - userId, - }: CommentsAndAuditSectionProps) { - + auditEvents, + newComment, + setNewComment, + handleCommentSubmit, + validatorUser, + userId, + commentUserAvatarUrls, +}: CommentsAndAuditSectionProps) { const [activeTab, setActiveTab] = useState(0); const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { setActiveTab(newValue); }; - // Collect all unique user IDs from auditEvents for avatar fetching - const commentUserIds = Array.from(new Set(auditEvents.map(ev => ev.User))); - const { avatars: commentUserAvatarUrls } = useBatchUserAvatars(commentUserIds); - return ( diff --git a/web/src/app/_components/review/ReviewItem.tsx b/web/src/app/_components/review/ReviewItem.tsx index cbf1a83..7d20109 100644 --- a/web/src/app/_components/review/ReviewItem.tsx +++ b/web/src/app/_components/review/ReviewItem.tsx @@ -3,7 +3,6 @@ import { ReviewItemHeader } from "./ReviewItemHeader"; import { CopyableField } from "@/app/_components/review/CopyableField"; import { SubmissionInfo } from "@/app/ts/Submission"; import { MapfixInfo } from "@/app/ts/Mapfix"; -import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; // Define a field configuration for specific types interface FieldConfig { @@ -17,14 +16,16 @@ type ReviewItemType = SubmissionInfo | MapfixInfo; interface ReviewItemProps { item: ReviewItemType; handleCopyValue: (value: string) => void; + submitterAvatarUrl?: string; + submitterUsername?: string; } export function ReviewItem({ - item, - handleCopyValue - }: ReviewItemProps) { - // Type guard to check if item is valid - const { avatars: submitterAvatars } = useBatchUserAvatars(item ? [item.Submitter] : []); + item, + handleCopyValue, + submitterAvatarUrl, + submitterUsername +}: ReviewItemProps) { if (!item) return null; // Determine the type of item @@ -54,7 +55,8 @@ export function ReviewItem({ statusId={item.StatusID} creator={item.Creator} submitterId={item.Submitter} - submitterAvatarUrl={submitterAvatars[item.Submitter]} + submitterAvatarUrl={submitterAvatarUrl} + submitterUsername={submitterUsername} /> {/* Item Details */} diff --git a/web/src/app/_components/review/ReviewItemHeader.tsx b/web/src/app/_components/review/ReviewItemHeader.tsx index 7c0b1c2..dbcc0f2 100644 --- a/web/src/app/_components/review/ReviewItemHeader.tsx +++ b/web/src/app/_components/review/ReviewItemHeader.tsx @@ -3,52 +3,19 @@ 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"; -import { useState, useEffect } from "react"; import Link from "next/link"; import LaunchIcon from '@mui/icons-material/Launch'; -function SubmitterName({ submitterId }: { submitterId: number }) { - const [name, setName] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (!submitterId) return; - const fetchUserName = async () => { - try { - setLoading(true); - const response = await fetch(`/proxy/users/${submitterId}`); - if (!response.ok) throw new Error('Failed to fetch user'); - const data = await response.json(); - setName(`@${data.name}`); - } catch { - setName(String(submitterId)); - } finally { - setLoading(false); - } - }; - fetchUserName(); - }, [submitterId]); - - if (loading) return Loading...; - return - - - {name || submitterId} - - - - -} - interface ReviewItemHeaderProps { displayName: string; statusId: SubmissionStatus | MapfixStatus; creator: string | null | undefined; submitterId: number; submitterAvatarUrl?: string; + submitterUsername?: string; } -export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId, submitterAvatarUrl }: ReviewItemHeaderProps) => { +export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId, submitterAvatarUrl, submitterUsername }: ReviewItemHeaderProps) => { const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]); const pulse = keyframes` 0%, 100% { opacity: 0.2; transform: scale(0.8); } @@ -94,7 +61,14 @@ export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId, src={submitterAvatarUrl} sx={{ mr: 1, width: 24, height: 24, border: '1px solid rgba(255, 255, 255, 0.1)', bgcolor: 'grey.900' }} /> - + + + + {submitterUsername ? `@${submitterUsername}` : submitterId} + + + + ); diff --git a/web/src/app/hooks/useBatchThumbnails.ts b/web/src/app/hooks/useBatchThumbnails.ts index 9392c5a..8914808 100644 --- a/web/src/app/hooks/useBatchThumbnails.ts +++ b/web/src/app/hooks/useBatchThumbnails.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; /** - * Fetches thumbnail URLs for a batch of asset IDs using the /thumbnails/batch endpoint. + * Fetches thumbnail URLs for a batch of asset IDs using the unified /thumbnails/batch endpoint. * Handles loading and error state. Returns a mapping of assetId to URL. */ export function useBatchThumbnails(assetIds: (number | string)[] | undefined) { @@ -26,7 +26,7 @@ export function useBatchThumbnails(assetIds: (number | string)[] | undefined) { } setLoading(true); setError(null); - fetch(`/thumbnails/batch?ids=${joinedIds}`) + fetch(`/thumbnails/batch?ids=${joinedIds}&type=asset`) .then(res => { if (!res.ok) throw new Error(`Failed to fetch thumbnails: ${res.status}`); return res.json(); @@ -35,7 +35,7 @@ export function useBatchThumbnails(assetIds: (number | string)[] | undefined) { .catch(err => setError(err)) .finally(() => setLoading(false)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [joinedIds]); // Only depend on joinedIds! + }, [joinedIds]); return { thumbnails, loading, error }; } diff --git a/web/src/app/hooks/useBatchUserAvatars.ts b/web/src/app/hooks/useBatchUserAvatars.ts index 4186718..3b4b3b2 100644 --- a/web/src/app/hooks/useBatchUserAvatars.ts +++ b/web/src/app/hooks/useBatchUserAvatars.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; /** - * Fetches avatar URLs for a batch of user IDs using the /thumbnails/user/batch endpoint. + * Fetches avatar URLs for a batch of user IDs using the unified /thumbnails/batch?type=user endpoint. * Returns a mapping of userId to avatar URL. */ export function useBatchUserAvatars(userIds: (number | string)[] | undefined) { @@ -27,7 +27,7 @@ export function useBatchUserAvatars(userIds: (number | string)[] | undefined) { } setLoading(true); setError(null); - fetch(`/thumbnails/user/batch?ids=${joinedIds}`) + fetch(`/thumbnails/batch?type=user&ids=${joinedIds}`) .then(res => { if (!res.ok) throw new Error(`Failed to fetch user avatars: ${res.status}`); return res.json(); @@ -35,6 +35,7 @@ export function useBatchUserAvatars(userIds: (number | string)[] | undefined) { .then(data => setAvatars(data)) .catch(err => setError(err)) .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [joinedIds]); return { avatars, loading, error }; diff --git a/web/src/app/mapfixes/[mapfixId]/page.tsx b/web/src/app/mapfixes/[mapfixId]/page.tsx index 738ea87..930f560 100644 --- a/web/src/app/mapfixes/[mapfixId]/page.tsx +++ b/web/src/app/mapfixes/[mapfixId]/page.tsx @@ -2,7 +2,7 @@ import Webpage from "@/app/_components/webpage"; import { useParams, useRouter } from "next/navigation"; -import {useEffect, useState} from "react"; +import { useState } from "react"; import Link from "next/link"; // MUI Components @@ -28,6 +28,9 @@ import ReviewButtons from "@/app/_components/review/ReviewButtons"; import {useReviewData} from "@/app/hooks/useReviewData"; import {MapfixInfo} from "@/app/ts/Mapfix"; import {useTitle} from "@/app/hooks/useTitle"; +import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; +import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; +import { useBatchUsernames } from "@/app/hooks/useBatchUsernames"; interface SnackbarState { open: boolean; @@ -45,7 +48,6 @@ export default function MapfixDetailsPage() { message: null, severity: 'success' }); - const [thumbnailUrls, setThumbnailUrls] = useState>({}); const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => { setSnackbar({ @@ -79,17 +81,33 @@ export default function MapfixDetailsPage() { useTitle(mapfix ? `${mapfix.DisplayName} Mapfix` : 'Loading Mapfix...'); - // Fetch thumbnails for mapfix images - useEffect(() => { - if (!mapfix?.AssetID && !mapfix?.TargetAssetID) { - setThumbnailUrls({}); - return; - } - const ids = [mapfix.TargetAssetID, mapfix.AssetID].filter(Boolean).join(","); - fetch(`/thumbnails/batch?ids=${ids}`) - .then(res => res.json()) - .then(data => setThumbnailUrls(data)); - }, [mapfix?.AssetID, mapfix?.TargetAssetID]); + // Fetch thumbnails for mapfix images using the hook + const assetIds = [mapfix?.TargetAssetID, mapfix?.AssetID].filter(Boolean); + const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds); + + // Gather all user IDs: submitter, commenters, audit event actors + const commentUserIds = (auditEvents || []) + .filter(ev => ev.UserID && ev.UserID > 0) + .map(ev => ev.UserID); + const submitterId = mapfix?.Submitter; + const allUserIds = Array.from(new Set([ + submitterId, + ...(commentUserIds || []) + ])).filter(Boolean); + + // Batch fetch avatars and usernames + const { avatars: userAvatars } = useBatchUserAvatars(allUserIds); + const { usernames: userUsernames } = useBatchUsernames(allUserIds); + + // Prepare avatar/username props for ReviewItem + const submitterAvatarUrl = submitterId ? userAvatars[submitterId] : undefined; + const submitterUsername = submitterId ? userUsernames[submitterId] : undefined; + + // Prepare avatar map for CommentsAndAuditSection + const commentUserAvatarUrls = {}; + for (const uid of commentUserIds) { + if (userAvatars[uid]) commentUserAvatarUrls[uid] = userAvatars[uid]; + } // Handle review button actions async function handleReviewAction(action: string, mapfixId: number) { @@ -370,6 +388,8 @@ export default function MapfixDetailsPage() { {/* Comments Section */} @@ -380,6 +400,7 @@ export default function MapfixDetailsPage() { handleCommentSubmit={handleCommentSubmit} validatorUser={validatorUser} userId={user} + commentUserAvatarUrls={commentUserAvatarUrls} /> diff --git a/web/src/app/mapfixes/page.tsx b/web/src/app/mapfixes/page.tsx index d8afe76..eb7ae4b 100644 --- a/web/src/app/mapfixes/page.tsx +++ b/web/src/app/mapfixes/page.tsx @@ -18,6 +18,7 @@ import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import {useTitle} from "@/app/hooks/useTitle"; import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; +import { useBatchUsernames } from "@/app/hooks/useBatchUsernames"; export default function MapfixInfoPage() { useTitle("Map Fixes"); @@ -25,7 +26,6 @@ export default function MapfixInfoPage() { const [mapfixes, setMapfixes] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); - const [submitterUsernames, setSubmitterUsernames] = useState>({}); const cardsPerPage = 24; useEffect(() => { @@ -64,30 +64,7 @@ export default function MapfixInfoPage() { // Collect unique submitter IDs for avatar and username fetching const submitterIds = mapfixes ? Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter))) : []; const { avatars: avatarUrls } = useBatchUserAvatars(submitterIds); - - // Fetch submitter usernames - useEffect(() => { - if (!mapfixes) return; - const uniqueIds = Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter))); - const fetchUsernames = async () => { - const results: Record = {}; - await Promise.all(uniqueIds.map(async (id) => { - try { - const res = await fetch(`/proxy/users/${id}`); - if (res.ok) { - const data = await res.json(); - results[id] = data.name || String(id); - } else { - results[id] = String(id); - } - } catch { - results[id] = String(id); - } - })); - setSubmitterUsernames(results); - }; - fetchUsernames(); - }, [mapfixes]); + const { usernames: submitterUsernames } = useBatchUsernames(submitterIds); if (isLoading || !mapfixes) { return ( diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index 8c346bc..47d6148 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -6,6 +6,7 @@ import { useParams, useRouter } from "next/navigation"; import React, { useState, useEffect } from "react"; import Link from "next/link"; import { Snackbar, Alert } from "@mui/material"; +import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; // MUI Components import { @@ -45,7 +46,6 @@ export default function MapDetails() { const [copySuccess, setCopySuccess] = useState(false); const [roles, setRoles] = useState(RolesConstants.Empty); const [downloading, setDownloading] = useState(false); - const [thumbnailUrl, setThumbnailUrl] = useState(null); useTitle(map ? `${map.DisplayName}` : 'Loading Map...'); @@ -89,15 +89,9 @@ export default function MapDetails() { getRoles() }, [mapId]); - useEffect(() => { - if (!map?.ID) { - setThumbnailUrl(null); - return; - } - fetch(`/thumbnails/batch?ids=${map.ID}`) - .then(res => res.json()) - .then(data => setThumbnailUrl(data[map.ID] || null)); - }, [map?.ID]); + // Use useBatchThumbnails for the map thumbnail + const assetIds = map?.ID ? [map.ID] : []; + const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds); const formatDate = (timestamp: number) => { return new Date(timestamp * 1000).toLocaleDateString('en-US', { @@ -326,10 +320,10 @@ export default function MapDetails() { position: 'relative' }} > - {thumbnailUrl ? ( + {thumbnailUrls[map.ID] ? ( (null); const [isLoadingMapfixes, setIsLoadingMapfixes] = useState(false); const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(false); - const [submissionUsernames, setSubmissionUsernames] = useState>({}); - const [mapfixUsernames, setMapfixUsernames] = useState>({}); const itemsPerSection: number = 8; // Show more items for the carousel useEffect(() => { @@ -87,54 +86,8 @@ export default function Home() { const mapfixAuthorIds = mapfixes ? Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter))) : []; const { avatars: submissionAvatars } = useBatchUserAvatars(submissionAuthorIds); const { avatars: mapfixAvatars } = useBatchUserAvatars(mapfixAuthorIds); - - // Fetch submitter usernames for submissions - useEffect(() => { - if (!submissions) return; - const uniqueIds = Array.from(new Set(submissions.Submissions.map(s => s.Submitter))); - const fetchUsernames = async () => { - const results: Record = {}; - await Promise.all(uniqueIds.map(async (id) => { - try { - const res = await fetch(`/proxy/users/${id}`); - if (res.ok) { - const data = await res.json(); - results[id] = data.name || String(id); - } else { - results[id] = String(id); - } - } catch { - results[id] = String(id); - } - })); - setSubmissionUsernames(results); - }; - fetchUsernames(); - }, [submissions]); - - // Fetch submitter usernames for mapfixes - useEffect(() => { - if (!mapfixes) return; - const uniqueIds = Array.from(new Set(mapfixes.Mapfixes.map(m => m.Submitter))); - const fetchUsernames = async () => { - const results: Record = {}; - await Promise.all(uniqueIds.map(async (id) => { - try { - const res = await fetch(`/proxy/users/${id}`); - if (res.ok) { - const data = await res.json(); - results[id] = data.name || String(id); - } else { - results[id] = String(id); - } - } catch { - results[id] = String(id); - } - })); - setMapfixUsernames(results); - }; - fetchUsernames(); - }, [mapfixes]); + const { usernames: submissionUsernames } = useBatchUsernames(submissionAuthorIds); + const { usernames: mapfixUsernames } = useBatchUsernames(mapfixAuthorIds); const isLoading: boolean = isLoadingMapfixes || isLoadingSubmissions; diff --git a/web/src/app/proxy/users/[userId]/route.ts b/web/src/app/proxy/users/[userId]/route.ts deleted file mode 100644 index d8087af..0000000 --- a/web/src/app/proxy/users/[userId]/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -// NOTE: This API endpoint proxies Roblox user info and implements in-memory rate limiting. -// For production, this logic should be moved to a dedicated backend API server (not serverless/edge) -// to allow for robust, distributed rate limiting and to avoid leaking your Roblox API quota. -// -// If you are behind a CDN/proxy, ensure you trust the IP headers. -// -// Consider using Redis or another distributed store for rate limiting in production. - -import { checkRateLimit } from '@/lib/rateLimit'; -import { NextResponse } from 'next/server'; - -export async function GET( - request: Request, - { params }: { params: Promise<{ userId: string }> } -) { - const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown'; - if (!checkRateLimit(ip)) { - return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); - } - - const { userId } = await params; - - if (!userId) { - return NextResponse.json({ error: 'User ID is required' }, { status: 400 }); - } - - try { - const apiResponse = await fetch(`https://users.roblox.com/v1/users/${userId}`); - - if (!apiResponse.ok) { - const errorData = await apiResponse.text(); - return NextResponse.json({ error: `Failed to fetch from Roblox API: ${errorData}` }, { status: apiResponse.status }); - } - - const data = await apiResponse.json(); - - // Add caching headers to the response - const headers = new Headers(); - headers.set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); // Cache for 1 hour - - return NextResponse.json(data, { headers }); - } catch { - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } -} diff --git a/web/src/app/submissions/[submissionId]/page.tsx b/web/src/app/submissions/[submissionId]/page.tsx index fbbaea5..ef32f7f 100644 --- a/web/src/app/submissions/[submissionId]/page.tsx +++ b/web/src/app/submissions/[submissionId]/page.tsx @@ -1,7 +1,7 @@ "use client"; import Webpage from "@/app/_components/webpage"; import { useParams, useRouter } from "next/navigation"; -import {useEffect, useState} from "react"; +import { useState } from "react"; import Link from "next/link"; // MUI Components @@ -27,6 +27,9 @@ import ReviewButtons from "@/app/_components/review/ReviewButtons"; import {useReviewData} from "@/app/hooks/useReviewData"; import {SubmissionInfo} from "@/app/ts/Submission"; import {useTitle} from "@/app/hooks/useTitle"; +import {useBatchThumbnails} from "@/app/hooks/useBatchThumbnails"; +import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; +import { useBatchUsernames } from "@/app/hooks/useBatchUsernames"; interface SnackbarState { open: boolean; @@ -43,23 +46,6 @@ export default function SubmissionDetailsPage() { message: null, severity: 'success' }); - const [thumbnailUrl, setThumbnailUrl] = useState(null); - const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => { - setSnackbar({ - open: true, - message, - severity - }); - }; - - const handleCloseSnackbar = () => { - setSnackbar({ - ...snackbar, - open: false - }); - }; - - const validatorUser = 9223372036854776000; const { @@ -78,16 +64,34 @@ export default function SubmissionDetailsPage() { useTitle(submission ? `${submission.DisplayName} Submission` : 'Loading Submission...'); - // Fetch thumbnail URL for the submission's AssetID - useEffect(() => { - if (!submission?.AssetID) { - setThumbnailUrl(null); - return; - } - fetch(`/thumbnails/batch?ids=${submission.AssetID}`) - .then(res => res.json()) - .then(data => setThumbnailUrl(data[submission.AssetID] || null)); - }, [submission?.AssetID]); + // Gather all user IDs and asset IDs needed for batch requests + const submitterId = submission?.Submitter; + const commentUserIds = auditEvents ? Array.from(new Set(auditEvents.map(ev => ev.User))) : []; + const allUserIds = [submitterId, ...commentUserIds].filter(Boolean); + const assetIds = submission?.AssetID ? [submission.AssetID] : []; + + console.log(allUserIds) + + // Batch fetch at the page level + const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds); + const { avatars: avatarUrls } = useBatchUserAvatars(allUserIds); + const { usernames: usernameMap } = useBatchUsernames(allUserIds); + + const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => { + setSnackbar({ + open: true, + message, + severity + }); + }; + + const handleCloseSnackbar = () => { + setSnackbar({ + ...snackbar, + open: false + }); + }; + // Handle review button actions async function handleReviewAction(action: string, submissionId: number) { @@ -217,10 +221,10 @@ export default function SubmissionDetailsPage() { {submission.AssetID ? ( - thumbnailUrl ? ( + thumbnailUrls[submission.AssetID] ? ( @@ -248,7 +252,7 @@ export default function SubmissionDetailsPage() { alignItems: 'center', justifyContent: 'center' }} - > + > No image available )} @@ -262,14 +266,14 @@ export default function SubmissionDetailsPage() { roles={roles} type="submission"/> - {/* Right Column - Submission Details and Comments */} - {/* Comments Section */} diff --git a/web/src/app/submissions/page.tsx b/web/src/app/submissions/page.tsx index 6d58985..97051b9 100644 --- a/web/src/app/submissions/page.tsx +++ b/web/src/app/submissions/page.tsx @@ -18,6 +18,7 @@ import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import {useTitle} from "@/app/hooks/useTitle"; import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; import { useBatchUserAvatars } from "@/app/hooks/useBatchUserAvatars"; +import { useBatchUsernames } from "@/app/hooks/useBatchUsernames"; export default function SubmissionInfoPage() { useTitle("Submissions"); @@ -25,7 +26,6 @@ export default function SubmissionInfoPage() { const [submissions, setSubmissions] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); - const [submitterUsernames, setSubmitterUsernames] = useState>({}); const cardsPerPage = 24; useEffect(() => { @@ -64,30 +64,7 @@ export default function SubmissionInfoPage() { // Collect submitter user IDs and fetch their avatars const submitterIds = submissions?.Submissions.map(s => s.Submitter) ?? []; const { avatars: submitterAvatars } = useBatchUserAvatars(submitterIds); - - // Fetch submitter usernames - useEffect(() => { - if (!submissions) return; - const uniqueIds = Array.from(new Set(submissions.Submissions.map(s => s.Submitter))); - const fetchUsernames = async () => { - const results: Record = {}; - await Promise.all(uniqueIds.map(async (id) => { - try { - const res = await fetch(`/proxy/users/${id}`); - if (res.ok) { - const data = await res.json(); - results[id] = data.name || String(id); - } else { - results[id] = String(id); - } - } catch { - results[id] = String(id); - } - })); - setSubmitterUsernames(results); - }; - fetchUsernames(); - }, [submissions]); + const { usernames: submitterUsernames } = useBatchUsernames(submitterIds); if (isLoading || !submissions) { return ( diff --git a/web/src/app/thumbnails/asset/[assetId]/route.tsx b/web/src/app/thumbnails/asset/[assetId]/route.tsx deleted file mode 100644 index d9f53de..0000000 --- a/web/src/app/thumbnails/asset/[assetId]/route.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @deprecated This endpoint is deprecated. Use /thumbnails/batch instead. This route will be removed in a future release. - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { errorImageResponse } from '@/app/lib/errorImageResponse'; - -const cache = new Map(); -const CACHE_TTL = 15 * 60 * 1000; - -export async function GET( - request: NextRequest, - context: { params: Promise<{ assetId: number }> } -): Promise { - const { assetId } = await context.params; - - if (!assetId) { - return errorImageResponse(400, { - message: "Missing asset ID", - }) - } - - let finalAssetId = assetId; - - try { - const mediaResponse = await fetch( - `https://publish.roblox.com/v1/assets/${assetId}/media` - ); - if (mediaResponse.ok) { - const mediaData = await mediaResponse.json(); - if (mediaData.data && mediaData.data.length > 0) { - finalAssetId = mediaData.data[0].toString(); - } - } - } catch { } - - const now = Date.now(); - const cached = cache.get(finalAssetId); - - if (cached && cached.expires > now) { - return new NextResponse(cached.buffer, { - headers: { - 'Content-Type': 'image/png', - 'Content-Length': cached.buffer.length.toString(), - 'Cache-Control': `public, max-age=${CACHE_TTL / 1000}`, - }, - }); - } - - try { - const response = await fetch( - `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${finalAssetId}` - ); - - if (!response.ok) { - throw new Error(`Failed to fetch thumbnail JSON [${response.status}]`) - } - - const data = await response.json(); - - const imageUrl = data.data[0]?.imageUrl; - if (!imageUrl) { - return errorImageResponse(404, { - message: "No image URL found in the response", - }) - } - - const imageResponse = await fetch(imageUrl); - if (!imageResponse.ok) { - throw new Error(`Failed to fetch the image [${imageResponse.status}]`) - } - - const arrayBuffer = await imageResponse.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - cache.set(finalAssetId, { buffer, expires: now + CACHE_TTL }); - - return new NextResponse(buffer, { - headers: { - 'Content-Type': 'image/png', - 'Content-Length': buffer.length.toString(), - 'Cache-Control': `public, max-age=${CACHE_TTL / 1000}`, - }, - }); - } catch (err) { - return errorImageResponse(500, { - message: `Failed to fetch or process thumbnail: ${err}`, - }) - } -} \ No newline at end of file diff --git a/web/src/app/thumbnails/batch/route.ts b/web/src/app/thumbnails/batch/route.ts index edb8bda..46a4ace 100644 --- a/web/src/app/thumbnails/batch/route.ts +++ b/web/src/app/thumbnails/batch/route.ts @@ -1,20 +1,35 @@ -// NOTE: This API endpoint proxies Roblox asset thumbnails and implements in-memory rate limiting. +// NOTE: This API endpoint proxies Roblox asset and user avatar thumbnails and implements in-memory rate limiting. // For production, this logic should be moved to a dedicated backend API server (not serverless/edge) // to allow for robust, distributed rate limiting and to avoid leaking your Roblox API quota. // // If you are behind a CDN/proxy, ensure you trust the IP headers. -// +// // Consider using Redis or another distributed store for rate limiting in production. import { NextRequest, NextResponse } from 'next/server'; import { checkRateLimit } from '@/lib/rateLimit'; -const CACHE_TTL = 6 * 60 * 60 * 1000; -const imageCache = new Map(); +const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours +const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour +const assetImageCache = new Map(); +const userImageCache = new Map(); + +// Periodic cleanup to prevent memory leaks +if (typeof globalThis.__thumbBatchCacheCleanup === 'undefined') { + globalThis.__thumbBatchCacheCleanup = setInterval(() => { + const now = Date.now(); + for (const [id, entry] of assetImageCache.entries()) { + if (entry.expires <= now) assetImageCache.delete(id); + } + for (const [id, entry] of userImageCache.entries()) { + if (entry.expires <= now) userImageCache.delete(id); + } + }, CACHE_CLEANUP_INTERVAL); +} interface RobloxThumbnailData { - targetId: number; - imageUrl?: string; + targetId: number; + imageUrl?: string; } export async function GET(request: NextRequest) { @@ -23,39 +38,46 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); } - const url = new URL(request.url); - const idsParam = url.searchParams.get('ids'); - if (!idsParam) { - return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 }); - } - const ids = idsParam.split(',').map(Number).filter(Boolean); - const now = Date.now(); - const result: Record = {}; - const idsToFetch: number[] = []; + const url = new URL(request.url); + const idsParam = url.searchParams.get('ids'); + const type = url.searchParams.get('type') || 'asset'; + if (!idsParam) { + return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 }); + } + const ids = idsParam.split(',').map(Number).filter(Boolean); + const now = Date.now(); + const result: Record = {}; + const idsToFetch: number[] = []; - for (const id of ids) { - const cached = imageCache.get(id); - if (cached && cached.expires > now) { - result[id] = cached.url; - } else { - idsToFetch.push(id); - } - } - - for (let i = 0; i < idsToFetch.length; i += 50) { - const batch = idsToFetch.slice(i, i + 50); - const response = await fetch( - `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${batch.join(',')}` - ); - if (!response.ok) continue; - const data = await response.json(); - for (const id of batch) { - const found = (data.data as RobloxThumbnailData[]).find(d => String(d.targetId) === String(id)); - const imageUrl = found?.imageUrl || null; - if (imageUrl) imageCache.set(id, { url: imageUrl, expires: now + CACHE_TTL }); - result[id] = imageUrl; - } - } + const cache = type === 'user' ? userImageCache : assetImageCache; - return NextResponse.json(result); + for (const id of ids) { + const cached = cache.get(id); + if (cached && cached.expires > now) { + result[id] = cached.url; + } else { + idsToFetch.push(id); + } + } + + for (let i = 0; i < idsToFetch.length; i += 50) { + const batch = idsToFetch.slice(i, i + 50); + let robloxUrl = ''; + if (type === 'user') { + robloxUrl = `https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${batch.join(',')}&size=420x420&format=Png&isCircular=false`; + } else { + robloxUrl = `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${batch.join(',')}`; + } + const response = await fetch(robloxUrl); + if (!response.ok) continue; + const data = await response.json(); + for (const id of batch) { + const found = (data.data as RobloxThumbnailData[]).find(d => String(d.targetId) === String(id)); + const imageUrl = found?.imageUrl || null; + if (imageUrl) cache.set(id, { url: imageUrl, expires: now + CACHE_TTL }); + result[id] = imageUrl; + } + } + + return NextResponse.json(result); } diff --git a/web/src/app/thumbnails/maps/[mapId]/route.tsx b/web/src/app/thumbnails/maps/[mapId]/route.tsx deleted file mode 100644 index 13a0dd4..0000000 --- a/web/src/app/thumbnails/maps/[mapId]/route.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { NextRequest, NextResponse } from "next/server" - -export async function GET( - request: NextRequest, - context: { params: Promise<{ mapId: string }> } -): Promise { - // TODO: implement this, we need a cdn for in-game map thumbnails... - - if (!process.env.API_HOST) { - throw new Error('env variable "API_HOST" is not set') - } - - const { mapId } = await context.params - - const apiHost = process.env.API_HOST.replace(/\/api\/?$/, "") - const redirectPath = `/thumbnails/asset/${mapId}` // The current date is Monday, June 30, 2025, 05:19:04 am. Let's see how long this line of code stays here (deprecated/waiting for thumbnails) - const redirectUrl = `${apiHost}${redirectPath}` - - return NextResponse.redirect(redirectUrl) -} \ No newline at end of file diff --git a/web/src/app/thumbnails/user/[userId]/route.tsx b/web/src/app/thumbnails/user/[userId]/route.tsx deleted file mode 100644 index af77ba8..0000000 --- a/web/src/app/thumbnails/user/[userId]/route.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @deprecated This endpoint is deprecated. Use /thumbnails/batch instead. This route will be removed in a future release. - */ - -import { NextRequest, NextResponse } from 'next/server'; - -const CACHE_TTL = 6 * 60 * 60 * 1000; -const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000; -const cache = new Map(); - -// Periodic cleanup to prevent memory leaks -if (typeof globalThis.__userThumbCacheCleanup === 'undefined') { - globalThis.__userThumbCacheCleanup = setInterval(() => { - const now = Date.now(); - for (const [userId, entry] of cache.entries()) { - if (entry.expires <= now) { - cache.delete(userId); - } - } - }, CACHE_CLEANUP_INTERVAL); -} - -export async function GET( - request: NextRequest, - context: { params: Promise<{ userId: number }> } -): Promise { - const { userId } = await context.params; // Await params to access userId - - if (!userId) { - return NextResponse.json( - { error: 'Missing userId parameter' }, - { status: 400 } - ); - } - - const now = Date.now(); - const cached = cache.get(userId); - if (cached && cached.expires > now) { - return NextResponse.redirect(cached.url); - } - - try { - const response = await fetch( - `https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${userId}&size=420x420&format=Png&isCircular=false` - ); - - if (!response.ok) { - throw new Error('Failed to fetch avatar headshot JSON'); - } - - const data = await response.json(); - - const imageUrl = data.data[0]?.imageUrl; - if (!imageUrl) { - return NextResponse.json( - { error: 'No image URL found in the response' }, - { status: 404 } - ); - } - - // Cache the result - cache.set(userId, { url: imageUrl, expires: now + CACHE_TTL }); - - // Redirect to the image URL instead of proxying - return NextResponse.redirect(imageUrl); - } catch { - return NextResponse.json( - { error: 'Failed to fetch avatar headshot URL' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/web/src/app/thumbnails/user/batch/route.ts b/web/src/app/thumbnails/user/batch/route.ts deleted file mode 100644 index 84242d5..0000000 --- a/web/src/app/thumbnails/user/batch/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -// NOTE: This API endpoint proxies Roblox avatar thumbnails and implements in-memory rate limiting. -// For production, this logic should be moved to a dedicated backend API server (not serverless/edge) -// to allow for robust, distributed rate limiting and to avoid leaking your Roblox API quota. -// -// If you are behind a CDN/proxy, ensure you trust the IP headers. -// -// Consider using Redis or another distributed store for rate limiting in production. - -import { NextRequest, NextResponse } from 'next/server'; -import { checkRateLimit } from '@/lib/rateLimit'; - -const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours -const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour -const userImageCache = new Map(); - -// Periodic cleanup to prevent memory leaks -if (typeof globalThis.__userBatchThumbCacheCleanup === 'undefined') { - globalThis.__userBatchThumbCacheCleanup = setInterval(() => { - const now = Date.now(); - for (const [userId, entry] of userImageCache.entries()) { - if (entry.expires <= now) { - userImageCache.delete(userId); - } - } - }, CACHE_CLEANUP_INTERVAL); -} - -interface RobloxUserThumbnailData { - targetId: number; - imageUrl?: string; -} - -export async function GET(request: NextRequest) { - const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown'; - if (!checkRateLimit(ip)) { - return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); - } - - const url = new URL(request.url); - const idsParam = url.searchParams.get('ids'); - if (!idsParam) { - return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 }); - } - const ids = idsParam.split(',').map(Number).filter(Boolean); - const now = Date.now(); - const result: Record = {}; - const idsToFetch: number[] = []; - - for (const id of ids) { - const cached = userImageCache.get(id); - if (cached && cached.expires > now) { - result[id] = cached.url; - } else { - idsToFetch.push(id); - } - } - - for (let i = 0; i < idsToFetch.length; i += 50) { - const batch = idsToFetch.slice(i, i + 50); - const response = await fetch( - `https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${batch.join(',')}&size=420x420&format=Png&isCircular=false` - ); - if (!response.ok) continue; - const data = await response.json(); - for (const id of batch) { - const found = (data.data as RobloxUserThumbnailData[]).find(d => String(d.targetId) === String(id)); - const imageUrl = found?.imageUrl || null; - if (imageUrl) userImageCache.set(id, { url: imageUrl, expires: now + CACHE_TTL }); - result[id] = imageUrl; - } - } - - return NextResponse.json(result); -} diff --git a/web/src/lib/rateLimit.ts b/web/src/lib/rateLimit.ts index 56e1f4c..e258abe 100644 --- a/web/src/lib/rateLimit.ts +++ b/web/src/lib/rateLimit.ts @@ -2,8 +2,8 @@ // Not for production-scale, but good for basic abuse prevention. // // For production, use a distributed store (e.g., Redis) and import this from a shared location. -const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute -const RATE_LIMIT_MAX = 20; // 20 requests per minute per IP +const RATE_LIMIT_WINDOW_MS = 60 * 1000; +const RATE_LIMIT_MAX = 30; // Map const ipRateLimitMap = new Map(); -- 2.49.1 From 82284947ee3bc7009e4450c170f056ecc3e0ee77 Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Tue, 1 Jul 2025 15:50:48 -0600 Subject: [PATCH 07/12] more batching & shii --- .../comments/AuditEventsTabPanel.tsx | 5 ++++- .../comments/CommentsAndAuditSection.tsx | 3 +++ web/src/app/mapfixes/[mapfixId]/page.tsx | 20 ++++++++++++++----- .../app/submissions/[submissionId]/page.tsx | 19 ++++++++++++++---- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/web/src/app/_components/comments/AuditEventsTabPanel.tsx b/web/src/app/_components/comments/AuditEventsTabPanel.tsx index d5b68cc..250d6bd 100644 --- a/web/src/app/_components/comments/AuditEventsTabPanel.tsx +++ b/web/src/app/_components/comments/AuditEventsTabPanel.tsx @@ -10,12 +10,14 @@ interface AuditEventsTabPanelProps { activeTab: number; auditEvents: AuditEvent[]; validatorUser: number; + auditEventUserAvatarUrls?: Record; } export default function AuditEventsTabPanel({ activeTab, auditEvents, - validatorUser + validatorUser, + auditEventUserAvatarUrls }: AuditEventsTabPanelProps) { const filteredEvents = auditEvents.filter( event => event.EventType !== AuditEventType.Comment @@ -30,6 +32,7 @@ export default function AuditEventsTabPanel({ key={index} event={event} validatorUser={validatorUser} + userAvatarUrl={auditEventUserAvatarUrls?.[event.User]} /> ))} diff --git a/web/src/app/_components/comments/CommentsAndAuditSection.tsx b/web/src/app/_components/comments/CommentsAndAuditSection.tsx index f0f4074..eb8908a 100644 --- a/web/src/app/_components/comments/CommentsAndAuditSection.tsx +++ b/web/src/app/_components/comments/CommentsAndAuditSection.tsx @@ -17,6 +17,7 @@ interface CommentsAndAuditSectionProps { validatorUser: number; userId: number | null; commentUserAvatarUrls: Record; + auditEventUserAvatarUrls?: Record; } export default function CommentsAndAuditSection({ @@ -27,6 +28,7 @@ export default function CommentsAndAuditSection({ validatorUser, userId, commentUserAvatarUrls, + auditEventUserAvatarUrls }: CommentsAndAuditSectionProps) { const [activeTab, setActiveTab] = useState(0); const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { @@ -61,6 +63,7 @@ export default function CommentsAndAuditSection({ activeTab={activeTab} auditEvents={auditEvents} validatorUser={validatorUser} + auditEventUserAvatarUrls={auditEventUserAvatarUrls} /> ); diff --git a/web/src/app/mapfixes/[mapfixId]/page.tsx b/web/src/app/mapfixes/[mapfixId]/page.tsx index 930f560..53c7b1e 100644 --- a/web/src/app/mapfixes/[mapfixId]/page.tsx +++ b/web/src/app/mapfixes/[mapfixId]/page.tsx @@ -87,28 +87,37 @@ export default function MapfixDetailsPage() { // Gather all user IDs: submitter, commenters, audit event actors const commentUserIds = (auditEvents || []) - .filter(ev => ev.UserID && ev.UserID > 0) - .map(ev => ev.UserID); + .filter(ev => ev.User && ev.User > 0) + .map(ev => ev.User); const submitterId = mapfix?.Submitter; const allUserIds = Array.from(new Set([ submitterId, ...(commentUserIds || []) ])).filter(Boolean); - // Batch fetch avatars and usernames + // Batch fetch avatars and submitter username only const { avatars: userAvatars } = useBatchUserAvatars(allUserIds); - const { usernames: userUsernames } = useBatchUsernames(allUserIds); + const { usernames: userUsernames } = useBatchUsernames([submitterId].filter(Boolean)); // Prepare avatar/username props for ReviewItem const submitterAvatarUrl = submitterId ? userAvatars[submitterId] : undefined; const submitterUsername = submitterId ? userUsernames[submitterId] : undefined; - // Prepare avatar map for CommentsAndAuditSection + // Prepare avatar map for CommentsAndAuditSection (comments) const commentUserAvatarUrls = {}; for (const uid of commentUserIds) { if (userAvatars[uid]) commentUserAvatarUrls[uid] = userAvatars[uid]; } + // Prepare avatar map for CommentsAndAuditSection (audit events) + const auditEventUserIds = (auditEvents || []) + .filter(ev => ev.User && ev.User > 0) + .map(ev => ev.User); + const auditEventUserAvatarUrls = {}; + for (const uid of auditEventUserIds) { + if (userAvatars[uid]) auditEventUserAvatarUrls[uid] = userAvatars[uid]; + } + // Handle review button actions async function handleReviewAction(action: string, mapfixId: number) { try { @@ -401,6 +410,7 @@ export default function MapfixDetailsPage() { validatorUser={validatorUser} userId={user} commentUserAvatarUrls={commentUserAvatarUrls} + auditEventUserAvatarUrls={auditEventUserAvatarUrls} /> diff --git a/web/src/app/submissions/[submissionId]/page.tsx b/web/src/app/submissions/[submissionId]/page.tsx index ef32f7f..ef1baa2 100644 --- a/web/src/app/submissions/[submissionId]/page.tsx +++ b/web/src/app/submissions/[submissionId]/page.tsx @@ -70,13 +70,24 @@ export default function SubmissionDetailsPage() { const allUserIds = [submitterId, ...commentUserIds].filter(Boolean); const assetIds = submission?.AssetID ? [submission.AssetID] : []; - console.log(allUserIds) - // Batch fetch at the page level const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds); const { avatars: avatarUrls } = useBatchUserAvatars(allUserIds); const { usernames: usernameMap } = useBatchUsernames(allUserIds); + // Prepare avatar map for CommentsAndAuditSection (comments) + const commentUserAvatarUrls = {}; + for (const uid of commentUserIds) { + if (avatarUrls[uid]) commentUserAvatarUrls[uid] = avatarUrls[uid]; + } + + // Prepare avatar map for CommentsAndAuditSection (audit events) + const auditEventUserIds = auditEvents ? Array.from(new Set(auditEvents.map(ev => ev.User))) : []; + const auditEventUserAvatarUrls = {}; + for (const uid of auditEventUserIds) { + if (avatarUrls[uid]) auditEventUserAvatarUrls[uid] = avatarUrls[uid]; + } + const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'success') => { setSnackbar({ open: true, @@ -92,7 +103,6 @@ export default function SubmissionDetailsPage() { }); }; - // Handle review button actions async function handleReviewAction(action: string, submissionId: number) { try { @@ -282,7 +292,8 @@ export default function SubmissionDetailsPage() { handleCommentSubmit={handleCommentSubmit} validatorUser={validatorUser} userId={user} - commentUserAvatarUrls={avatarUrls} + commentUserAvatarUrls={commentUserAvatarUrls} + auditEventUserAvatarUrls={auditEventUserAvatarUrls} /> -- 2.49.1 From 87c1d161fc458458548266b87fcdabeb88980cd8 Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Tue, 1 Jul 2025 16:45:04 -0600 Subject: [PATCH 08/12] Batch limits, AI suggested anti-spam. --- .../_components/comments/AuditEventItem.tsx | 1 + .../app/_components/comments/CommentItem.tsx | 1 + .../_components/comments/CommentsTabPanel.tsx | 3 +- web/src/app/hooks/useBatchThumbnails.ts | 40 +++++++--- web/src/app/hooks/useBatchUserAvatars.ts | 39 ++++++--- web/src/app/hooks/useBatchUsernames.ts | 42 ++++++---- web/src/app/mapfixes/[mapfixId]/page.tsx | 4 +- web/src/app/proxy/users/batch/route.ts | 79 +++++++++++-------- .../app/submissions/[submissionId]/page.tsx | 4 +- web/src/app/thumbnails/batch/route.ts | 75 ++++++++++++------ web/src/lib/getClientIp.ts | 16 ++++ web/src/lib/globalRateLimit.ts | 18 +++++ web/src/lib/rateLimit.ts | 20 ++--- 13 files changed, 240 insertions(+), 102 deletions(-) create mode 100644 web/src/lib/getClientIp.ts create mode 100644 web/src/lib/globalRateLimit.ts diff --git a/web/src/app/_components/comments/AuditEventItem.tsx b/web/src/app/_components/comments/AuditEventItem.tsx index fbf79d5..c5930dd 100644 --- a/web/src/app/_components/comments/AuditEventItem.tsx +++ b/web/src/app/_components/comments/AuditEventItem.tsx @@ -20,6 +20,7 @@ export default function AuditEventItem({ event, validatorUser, userAvatarUrl }: diff --git a/web/src/app/_components/comments/CommentItem.tsx b/web/src/app/_components/comments/CommentItem.tsx index 931724f..2f7e116 100644 --- a/web/src/app/_components/comments/CommentItem.tsx +++ b/web/src/app/_components/comments/CommentItem.tsx @@ -20,6 +20,7 @@ export default function CommentItem({ event, validatorUser, userAvatarUrl }: Com diff --git a/web/src/app/_components/comments/CommentsTabPanel.tsx b/web/src/app/_components/comments/CommentsTabPanel.tsx index 86404ad..2fc9c4c 100644 --- a/web/src/app/_components/comments/CommentsTabPanel.tsx +++ b/web/src/app/_components/comments/CommentsTabPanel.tsx @@ -81,11 +81,12 @@ interface CommentInputProps { userAvatarUrl?: string; } -function CommentInput({ newComment, setNewComment, handleCommentSubmit, userId, userAvatarUrl }: CommentInputProps) { +function CommentInput({ newComment, setNewComment, handleCommentSubmit, userAvatarUrl }: CommentInputProps) { return ( (arr: T[], size: number): T[][] { + const res: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + res.push(arr.slice(i, i + size)); + } + return res; +} + /** * Fetches thumbnail URLs for a batch of asset IDs using the unified /thumbnails/batch endpoint. * Handles loading and error state. Returns a mapping of assetId to URL. @@ -9,8 +17,6 @@ export function useBatchThumbnails(assetIds: (number | string)[] | undefined) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const joinedIds = (assetIds && assetIds.filter(Boolean).join(",")) || ""; - useEffect(() => { if (!assetIds || assetIds.length === 0) { setThumbnails({}); @@ -18,7 +24,8 @@ export function useBatchThumbnails(assetIds: (number | string)[] | undefined) { setError(null); return; } - if (!joinedIds) { + const filteredIds = assetIds.filter(Boolean); + if (filteredIds.length === 0) { setThumbnails({}); setLoading(false); setError(null); @@ -26,16 +33,29 @@ export function useBatchThumbnails(assetIds: (number | string)[] | undefined) { } setLoading(true); setError(null); - fetch(`/thumbnails/batch?ids=${joinedIds}&type=asset`) - .then(res => { - if (!res.ok) throw new Error(`Failed to fetch thumbnails: ${res.status}`); - return res.json(); + const chunks = chunkArray(filteredIds, 50); + Promise.all( + chunks.map(chunk => + fetch(`/thumbnails/batch?ids=${chunk.join(",")}&type=asset`) + .then(res => { + if (!res.ok) throw new Error(`Failed to fetch thumbnails: ${res.status}`); + return res.json(); + }) + ) + ) + .then(datas => { + const result: Record = {}; + for (const data of datas) { + for (const [id, url] of Object.entries(data)) { + if (url) result[Number(id)] = url as string; + } + } + setThumbnails(result); }) - .then(data => setThumbnails(data)) .catch(err => setError(err)) .finally(() => setLoading(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [joinedIds]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assetIds && assetIds.filter(Boolean).join(",")]); return { thumbnails, loading, error }; } diff --git a/web/src/app/hooks/useBatchUserAvatars.ts b/web/src/app/hooks/useBatchUserAvatars.ts index 3b4b3b2..84bc42c 100644 --- a/web/src/app/hooks/useBatchUserAvatars.ts +++ b/web/src/app/hooks/useBatchUserAvatars.ts @@ -1,5 +1,13 @@ import { useState, useEffect } from "react"; +function chunkArray(arr: T[], size: number): T[][] { + const res: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + res.push(arr.slice(i, i + size)); + } + return res; +} + /** * Fetches avatar URLs for a batch of user IDs using the unified /thumbnails/batch?type=user endpoint. * Returns a mapping of userId to avatar URL. @@ -9,9 +17,6 @@ export function useBatchUserAvatars(userIds: (number | string)[] | undefined) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const joinedIds = (userIds && userIds.filter(Boolean).join(",")) || ""; - - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (!userIds || userIds.length === 0) { setAvatars({}); @@ -19,7 +24,8 @@ export function useBatchUserAvatars(userIds: (number | string)[] | undefined) { setError(null); return; } - if (!joinedIds) { + const filteredIds = userIds.filter(Boolean); + if (filteredIds.length === 0) { setAvatars({}); setLoading(false); setError(null); @@ -27,16 +33,29 @@ export function useBatchUserAvatars(userIds: (number | string)[] | undefined) { } setLoading(true); setError(null); - fetch(`/thumbnails/batch?type=user&ids=${joinedIds}`) - .then(res => { - if (!res.ok) throw new Error(`Failed to fetch user avatars: ${res.status}`); - return res.json(); + const chunks = chunkArray(filteredIds, 50); + Promise.all( + chunks.map(chunk => + fetch(`/thumbnails/batch?type=user&ids=${chunk.join(",")}`) + .then(res => { + if (!res.ok) throw new Error(`Failed to fetch user avatars: ${res.status}`); + return res.json(); + }) + ) + ) + .then(datas => { + const result: Record = {}; + for (const data of datas) { + for (const [id, url] of Object.entries(data)) { + if (url) result[Number(id)] = url as string; + } + } + setAvatars(result); }) - .then(data => setAvatars(data)) .catch(err => setError(err)) .finally(() => setLoading(false)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [joinedIds]); + }, [userIds && userIds.filter(Boolean).join(",")]); return { avatars, loading, error }; } diff --git a/web/src/app/hooks/useBatchUsernames.ts b/web/src/app/hooks/useBatchUsernames.ts index dd82d30..cc8e90c 100644 --- a/web/src/app/hooks/useBatchUsernames.ts +++ b/web/src/app/hooks/useBatchUsernames.ts @@ -1,5 +1,13 @@ import { useState, useEffect } from "react"; +function chunkArray(arr: T[], size: number): T[][] { + const res: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + res.push(arr.slice(i, i + size)); + } + return res; +} + /** * Fetches usernames for a batch of user IDs using the /proxy/users/batch?ids=... endpoint. * Returns a mapping of userId to username (or userId as string if not found). @@ -9,8 +17,6 @@ export function useBatchUsernames(userIds: (number | string)[] | undefined) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const joinedIds = (userIds && userIds.filter(Boolean).join(",")) || ""; - useEffect(() => { if (!userIds || userIds.length === 0) { setUsernames({}); @@ -18,7 +24,8 @@ export function useBatchUsernames(userIds: (number | string)[] | undefined) { setError(null); return; } - if (!joinedIds) { + const filteredIds = userIds.filter(Boolean); + if (filteredIds.length === 0) { setUsernames({}); setLoading(false); setError(null); @@ -26,24 +33,31 @@ export function useBatchUsernames(userIds: (number | string)[] | undefined) { } setLoading(true); setError(null); - fetch(`/proxy/users/batch?ids=${joinedIds}`) - .then(res => { - if (!res.ok) throw new Error(`Failed to fetch usernames: ${res.status}`); - return res.json(); - }) - .then(data => { + const chunks = chunkArray(filteredIds, 50); + Promise.all( + chunks.map(chunk => + fetch(`/proxy/users/batch?ids=${chunk.join(",")}`) + .then(res => { + if (!res.ok) throw new Error(`Failed to fetch usernames: ${res.status}`); + return res.json(); + }) + ) + ) + .then(datas => { const result: Record = {}; - if (Array.isArray(data.data)) { - for (const user of data.data) { - result[user.id] = user.name || String(user.id); + for (const data of datas) { + if (Array.isArray(data.data)) { + for (const user of data.data) { + result[user.id] = user.name || String(user.id); + } } } setUsernames(result); }) .catch(err => setError(err)) .finally(() => setLoading(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [joinedIds]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userIds && userIds.filter(Boolean).join(",")]); return { usernames, loading, error }; } diff --git a/web/src/app/mapfixes/[mapfixId]/page.tsx b/web/src/app/mapfixes/[mapfixId]/page.tsx index 53c7b1e..87ee9ed 100644 --- a/web/src/app/mapfixes/[mapfixId]/page.tsx +++ b/web/src/app/mapfixes/[mapfixId]/page.tsx @@ -104,7 +104,7 @@ export default function MapfixDetailsPage() { const submitterUsername = submitterId ? userUsernames[submitterId] : undefined; // Prepare avatar map for CommentsAndAuditSection (comments) - const commentUserAvatarUrls = {}; + const commentUserAvatarUrls: Record = {}; for (const uid of commentUserIds) { if (userAvatars[uid]) commentUserAvatarUrls[uid] = userAvatars[uid]; } @@ -113,7 +113,7 @@ export default function MapfixDetailsPage() { const auditEventUserIds = (auditEvents || []) .filter(ev => ev.User && ev.User > 0) .map(ev => ev.User); - const auditEventUserAvatarUrls = {}; + const auditEventUserAvatarUrls: Record = {}; for (const uid of auditEventUserIds) { if (userAvatars[uid]) auditEventUserAvatarUrls[uid] = userAvatars[uid]; } diff --git a/web/src/app/proxy/users/batch/route.ts b/web/src/app/proxy/users/batch/route.ts index 0e1186d..1d14bfd 100644 --- a/web/src/app/proxy/users/batch/route.ts +++ b/web/src/app/proxy/users/batch/route.ts @@ -7,36 +7,53 @@ import { checkRateLimit } from '@/lib/rateLimit'; import { NextResponse } from 'next/server'; +import { getClientIp } from '@/lib/getClientIp'; +import { createGlobalRateLimiter } from '@/lib/globalRateLimit'; + +const checkGlobalRateLimit = createGlobalRateLimiter(500, 5 * 60 * 1000); // 500 per 5 min + +const VALIDATOR_USER_ID = 9223372036854776000; export async function GET(request: Request) { - const url = new URL(request.url); - const idsParam = url.searchParams.get('ids'); - const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown'; - if (!checkRateLimit(ip)) { - return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); - } - if (!idsParam) { - return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 }); - } - const userIds = idsParam.split(',').map(Number).filter(Boolean); - if (userIds.length === 0) { - return NextResponse.json({ error: 'No valid user IDs provided' }, { status: 400 }); - } - try { - const apiResponse = await fetch('https://users.roblox.com/v1/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userIds }), - }); - if (!apiResponse.ok) { - const errorData = await apiResponse.text(); - return NextResponse.json({ error: `Failed to fetch from Roblox API: ${errorData}` }, { status: apiResponse.status }); - } - const data = await apiResponse.json(); - const headers = new Headers(); - headers.set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); - return NextResponse.json(data, { headers }); - } catch { - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } -} + const url = new URL(request.url); + const idsParam = url.searchParams.get('ids'); + const ip = getClientIp(request); + if (!checkRateLimit(ip)) { + return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); + } + if (!checkGlobalRateLimit()) { + return NextResponse.json({ error: 'Server busy. Please try again later.' }, { status: 429 }); + } + if (!idsParam) { + return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 }); + } + let userIds = idsParam + .split(',') + .map(Number) + .filter(id => Number.isInteger(id) && id > 0 && id !== VALIDATOR_USER_ID); + // De-duplicate + userIds = Array.from(new Set(userIds)); + if (userIds.length === 0) { + return NextResponse.json({ error: 'No valid user IDs provided' }, { status: 400 }); + } + if (userIds.length > 50) { + return NextResponse.json({ error: 'Too many user IDs in batch (max 50)' }, { status: 400 }); + } + try { + const apiResponse = await fetch('https://users.roblox.com/v1/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userIds }), + }); + if (!apiResponse.ok) { + const errorData = await apiResponse.text(); + return NextResponse.json({ error: `Failed to fetch from Roblox API: ${errorData}` }, { status: apiResponse.status }); + } + const data = await apiResponse.json(); + const headers = new Headers(); + headers.set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); + return NextResponse.json(data, { headers }); + } catch { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/web/src/app/submissions/[submissionId]/page.tsx b/web/src/app/submissions/[submissionId]/page.tsx index ef1baa2..52b7add 100644 --- a/web/src/app/submissions/[submissionId]/page.tsx +++ b/web/src/app/submissions/[submissionId]/page.tsx @@ -76,14 +76,14 @@ export default function SubmissionDetailsPage() { const { usernames: usernameMap } = useBatchUsernames(allUserIds); // Prepare avatar map for CommentsAndAuditSection (comments) - const commentUserAvatarUrls = {}; + const commentUserAvatarUrls: Record = {}; for (const uid of commentUserIds) { if (avatarUrls[uid]) commentUserAvatarUrls[uid] = avatarUrls[uid]; } // Prepare avatar map for CommentsAndAuditSection (audit events) const auditEventUserIds = auditEvents ? Array.from(new Set(auditEvents.map(ev => ev.User))) : []; - const auditEventUserAvatarUrls = {}; + const auditEventUserAvatarUrls: Record = {}; for (const uid of auditEventUserIds) { if (avatarUrls[uid]) auditEventUserAvatarUrls[uid] = avatarUrls[uid]; } diff --git a/web/src/app/thumbnails/batch/route.ts b/web/src/app/thumbnails/batch/route.ts index 46a4ace..52c0c35 100644 --- a/web/src/app/thumbnails/batch/route.ts +++ b/web/src/app/thumbnails/batch/route.ts @@ -8,34 +8,46 @@ import { NextRequest, NextResponse } from 'next/server'; import { checkRateLimit } from '@/lib/rateLimit'; +import { getClientIp } from '@/lib/getClientIp'; +import { createGlobalRateLimiter } from '@/lib/globalRateLimit'; const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour const assetImageCache = new Map(); const userImageCache = new Map(); -// Periodic cleanup to prevent memory leaks -if (typeof globalThis.__thumbBatchCacheCleanup === 'undefined') { - globalThis.__thumbBatchCacheCleanup = setInterval(() => { - const now = Date.now(); +// Cleanup state +let lastCacheCleanup = 0; + +// Global rate limiting +const checkGlobalRateLimit = createGlobalRateLimiter(500, 5 * 60 * 1000); // 500 per 5 min + +const VALIDATOR_USER_ID = 9223372036854776000; + +type RobloxThumbnailData = { + targetId: number; + imageUrl?: string; +}; + +export async function GET(request: NextRequest) { + const ip = getClientIp(request); + if (!checkRateLimit(ip)) { + return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); + } + if (!checkGlobalRateLimit()) { + return NextResponse.json({ error: 'Server busy. Please try again later.' }, { status: 429 }); + } + + const now = Date.now(); + // Cleanup cache if needed + if (now - lastCacheCleanup > CACHE_CLEANUP_INTERVAL) { for (const [id, entry] of assetImageCache.entries()) { if (entry.expires <= now) assetImageCache.delete(id); } for (const [id, entry] of userImageCache.entries()) { if (entry.expires <= now) userImageCache.delete(id); } - }, CACHE_CLEANUP_INTERVAL); -} - -interface RobloxThumbnailData { - targetId: number; - imageUrl?: string; -} - -export async function GET(request: NextRequest) { - const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.ip || 'unknown'; - if (!checkRateLimit(ip)) { - return NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }); + lastCacheCleanup = now; } const url = new URL(request.url); @@ -44,8 +56,18 @@ export async function GET(request: NextRequest) { if (!idsParam) { return NextResponse.json({ error: 'Missing ids parameter' }, { status: 400 }); } - const ids = idsParam.split(',').map(Number).filter(Boolean); - const now = Date.now(); + let ids = idsParam + .split(',') + .map(Number) + .filter(id => Number.isInteger(id) && id > 0 && id !== VALIDATOR_USER_ID); + // De-duplicate + ids = Array.from(new Set(ids)); + if (ids.length === 0) { + return NextResponse.json({ error: 'No valid IDs provided' }, { status: 400 }); + } + if (ids.length > 50) { + return NextResponse.json({ error: 'Too many IDs in batch (max 50)' }, { status: 400 }); + } const result: Record = {}; const idsToFetch: number[] = []; @@ -69,15 +91,24 @@ export async function GET(request: NextRequest) { robloxUrl = `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${batch.join(',')}`; } const response = await fetch(robloxUrl); - if (!response.ok) continue; + if (!response.ok) { + for (const id of batch) { + result[id] = null; + } + continue; + } const data = await response.json(); for (const id of batch) { const found = (data.data as RobloxThumbnailData[]).find(d => String(d.targetId) === String(id)); const imageUrl = found?.imageUrl || null; - if (imageUrl) cache.set(id, { url: imageUrl, expires: now + CACHE_TTL }); - result[id] = imageUrl; + if (imageUrl) { + cache.set(id, { url: imageUrl, expires: now + CACHE_TTL }); + result[id] = imageUrl; + } else { + result[id] = null; + } } } return NextResponse.json(result); -} +} \ No newline at end of file diff --git a/web/src/lib/getClientIp.ts b/web/src/lib/getClientIp.ts new file mode 100644 index 0000000..754df3b --- /dev/null +++ b/web/src/lib/getClientIp.ts @@ -0,0 +1,16 @@ +import { NextRequest } from 'next/server'; + +/** + * Extracts the client IP address from a Next.js request, trusting only proxy headers. + * Only use this if you are behind a trusted proxy (e.g., nginx). + */ +export function getClientIp(request: NextRequest | Request): string { + // X-Forwarded-For may be a comma-separated list. The left-most is the original client. + const xff = request.headers.get('x-forwarded-for'); + if (xff) { + return xff.split(',')[0].trim(); + } + const xRealIp = request.headers.get('x-real-ip'); + if (xRealIp) return xRealIp.trim(); + return 'unknown'; +} diff --git a/web/src/lib/globalRateLimit.ts b/web/src/lib/globalRateLimit.ts new file mode 100644 index 0000000..e1ccd2f --- /dev/null +++ b/web/src/lib/globalRateLimit.ts @@ -0,0 +1,18 @@ +/** + * Returns a global rate limiter function with its own state. + * Usage: const checkGlobalRateLimit = createGlobalRateLimiter(500, 5 * 60 * 1000); + */ +export function createGlobalRateLimiter(limit: number, windowMs: number) { + let count = 0; + let lastReset = Date.now(); + return function checkGlobalRateLimit() { + const now = Date.now(); + if (now - lastReset > windowMs) { + count = 0; + lastReset = now; + } + if (count >= limit) return false; + count++; + return true; + }; +} \ No newline at end of file diff --git a/web/src/lib/rateLimit.ts b/web/src/lib/rateLimit.ts index e258abe..f025759 100644 --- a/web/src/lib/rateLimit.ts +++ b/web/src/lib/rateLimit.ts @@ -8,8 +8,17 @@ const RATE_LIMIT_MAX = 30; // Map const ipRateLimitMap = new Map(); +let lastIpRateLimitCleanup = 0; + export function checkRateLimit(ip: string): boolean { const now = Date.now(); + // Cleanup expired entries if needed + if (now - lastIpRateLimitCleanup > RATE_LIMIT_WINDOW_MS) { + for (const [ip, entry] of ipRateLimitMap.entries()) { + if (entry.expires < now) ipRateLimitMap.delete(ip); + } + lastIpRateLimitCleanup = now; + } const entry = ipRateLimitMap.get(ip); if (!entry || entry.expires < now) { ipRateLimitMap.set(ip, { count: 1, expires: now + RATE_LIMIT_WINDOW_MS }); @@ -20,13 +29,4 @@ export function checkRateLimit(ip: string): boolean { } entry.count++; return true; -} - -if (typeof globalThis.__ipRateLimitCleanup === 'undefined') { - globalThis.__ipRateLimitCleanup = setInterval(() => { - const now = Date.now(); - for (const [ip, entry] of ipRateLimitMap.entries()) { - if (entry.expires < now) ipRateLimitMap.delete(ip); - } - }, RATE_LIMIT_WINDOW_MS); -} +} \ No newline at end of file -- 2.49.1 From 709bb708d3db1c7a7937479745a43d8a2feda720 Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Tue, 1 Jul 2025 17:17:07 -0600 Subject: [PATCH 09/12] build succeed + use media for thumbnail if available --- web/src/app/hooks/useBatchThumbnails.ts | 96 ++++++++++++------------- web/src/app/thumbnails/batch/route.ts | 29 ++++++-- 2 files changed, 72 insertions(+), 53 deletions(-) diff --git a/web/src/app/hooks/useBatchThumbnails.ts b/web/src/app/hooks/useBatchThumbnails.ts index 91c0138..e860413 100644 --- a/web/src/app/hooks/useBatchThumbnails.ts +++ b/web/src/app/hooks/useBatchThumbnails.ts @@ -1,11 +1,11 @@ import { useState, useEffect } from "react"; function chunkArray(arr: T[], size: number): T[][] { - const res: T[][] = []; - for (let i = 0; i < arr.length; i += size) { - res.push(arr.slice(i, i + size)); - } - return res; + const res: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + res.push(arr.slice(i, i + size)); + } + return res; } /** @@ -13,49 +13,49 @@ function chunkArray(arr: T[], size: number): T[][] { * Handles loading and error state. Returns a mapping of assetId to URL. */ export function useBatchThumbnails(assetIds: (number | string)[] | undefined) { - const [thumbnails, setThumbnails] = useState>({}); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [thumbnails, setThumbnails] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); - useEffect(() => { - if (!assetIds || assetIds.length === 0) { - setThumbnails({}); - setLoading(false); - setError(null); - return; - } - const filteredIds = assetIds.filter(Boolean); - if (filteredIds.length === 0) { - setThumbnails({}); - setLoading(false); - setError(null); - return; - } - setLoading(true); - setError(null); - const chunks = chunkArray(filteredIds, 50); - Promise.all( - chunks.map(chunk => - fetch(`/thumbnails/batch?ids=${chunk.join(",")}&type=asset`) - .then(res => { - if (!res.ok) throw new Error(`Failed to fetch thumbnails: ${res.status}`); - return res.json(); - }) - ) - ) - .then(datas => { - const result: Record = {}; - for (const data of datas) { - for (const [id, url] of Object.entries(data)) { - if (url) result[Number(id)] = url as string; - } - } - setThumbnails(result); - }) - .catch(err => setError(err)) - .finally(() => setLoading(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [assetIds && assetIds.filter(Boolean).join(",")]); + useEffect(() => { + if (!assetIds || assetIds.length === 0) { + setThumbnails({}); + setLoading(false); + setError(null); + return; + } + const filteredIds = assetIds.filter(Boolean); + if (filteredIds.length === 0) { + setThumbnails({}); + setLoading(false); + setError(null); + return; + } + setLoading(true); + setError(null); + const chunks = chunkArray(filteredIds, 50); + Promise.all( + chunks.map(chunk => + fetch(`/thumbnails/batch?type=asset&ids=${chunk.join(",")}&type=asset`) + .then(res => { + if (!res.ok) throw new Error(`Failed to fetch thumbnails: ${res.status}`); + return res.json(); + }) + ) + ) + .then(datas => { + const result: Record = {}; + for (const data of datas) { + for (const [id, url] of Object.entries(data)) { + if (url) result[Number(id)] = url as string; + } + } + setThumbnails(result); + }) + .catch(err => setError(err)) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assetIds && assetIds.filter(Boolean).join(",")]); - return { thumbnails, loading, error }; + return { thumbnails, loading, error }; } diff --git a/web/src/app/thumbnails/batch/route.ts b/web/src/app/thumbnails/batch/route.ts index 52c0c35..4b3e2f1 100644 --- a/web/src/app/thumbnails/batch/route.ts +++ b/web/src/app/thumbnails/batch/route.ts @@ -85,10 +85,27 @@ export async function GET(request: NextRequest) { for (let i = 0; i < idsToFetch.length; i += 50) { const batch = idsToFetch.slice(i, i + 50); let robloxUrl = ''; - if (type === 'user') { - robloxUrl = `https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${batch.join(',')}&size=420x420&format=Png&isCircular=false`; + let finalBatch = batch; + if (type === 'asset') { + finalBatch = []; + for (const assetId of batch) { + let finalAssetId = assetId; + try { + const mediaResponse = await fetch( + `https://publish.roblox.com/v1/assets/${assetId}/media` + ); + if (mediaResponse.ok) { + const mediaData = await mediaResponse.json(); + if (mediaData.data && mediaData.data.length > 0) { + finalAssetId = Number(mediaData.data[0].id || mediaData.data[0]); + } + } + } catch {} + finalBatch.push(finalAssetId); + } + robloxUrl = `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${finalBatch.join(',')}`; } else { - robloxUrl = `https://thumbnails.roblox.com/v1/assets?format=png&size=512x512&assetIds=${batch.join(',')}`; + robloxUrl = `https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${batch.join(',')}&size=100x100&format=Png&isCircular=false`; } const response = await fetch(robloxUrl); if (!response.ok) { @@ -98,8 +115,10 @@ export async function GET(request: NextRequest) { continue; } const data = await response.json(); - for (const id of batch) { - const found = (data.data as RobloxThumbnailData[]).find(d => String(d.targetId) === String(id)); + for (let j = 0; j < batch.length; j++) { + const id = batch[j]; + const lookupId = type === 'asset' ? finalBatch[j] : id; + const found = (data.data as RobloxThumbnailData[]).find(d => String(d.targetId) === String(lookupId)); const imageUrl = found?.imageUrl || null; if (imageUrl) { cache.set(id, { url: imageUrl, expires: now + CACHE_TTL }); -- 2.49.1 From ba5e449569b9fff9c66d298030923e619f3e7caf Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Tue, 1 Jul 2025 17:31:35 -0600 Subject: [PATCH 10/12] User information caching --- web/src/app/proxy/users/batch/route.ts | 70 ++++++++++++++++++++------ 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/web/src/app/proxy/users/batch/route.ts b/web/src/app/proxy/users/batch/route.ts index 1d14bfd..603ee5c 100644 --- a/web/src/app/proxy/users/batch/route.ts +++ b/web/src/app/proxy/users/batch/route.ts @@ -9,11 +9,16 @@ import { checkRateLimit } from '@/lib/rateLimit'; import { NextResponse } from 'next/server'; import { getClientIp } from '@/lib/getClientIp'; import { createGlobalRateLimiter } from '@/lib/globalRateLimit'; +import type { RobloxUserInfo } from './RobloxUserInfo'; const checkGlobalRateLimit = createGlobalRateLimiter(500, 5 * 60 * 1000); // 500 per 5 min const VALIDATOR_USER_ID = 9223372036854776000; +const USER_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours +const userInfoCache = new Map(); +let lastUserCacheCleanup = 0; + export async function GET(request: Request) { const url = new URL(request.url); const idsParam = url.searchParams.get('ids'); @@ -39,21 +44,56 @@ export async function GET(request: Request) { if (userIds.length > 50) { return NextResponse.json({ error: 'Too many user IDs in batch (max 50)' }, { status: 400 }); } - try { - const apiResponse = await fetch('https://users.roblox.com/v1/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userIds }), - }); - if (!apiResponse.ok) { - const errorData = await apiResponse.text(); - return NextResponse.json({ error: `Failed to fetch from Roblox API: ${errorData}` }, { status: apiResponse.status }); + + const now = Date.now(); + // Cleanup expired cache entries + if (now - lastUserCacheCleanup > USER_CACHE_TTL) { + for (const [id, entry] of userInfoCache.entries()) { + if (entry.expires <= now) userInfoCache.delete(id); } - const data = await apiResponse.json(); - const headers = new Headers(); - headers.set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); - return NextResponse.json(data, { headers }); - } catch { - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + lastUserCacheCleanup = now; } + + const result: RobloxUserInfo[] = []; + const idsToFetch: number[] = []; + const cachedMap: Record = {}; + for (const id of userIds) { + const cached = userInfoCache.get(id); + if (cached && cached.expires > now) { + cachedMap[id] = cached.info; + result.push(cached.info); + } else { + idsToFetch.push(id); + } + } + + if (idsToFetch.length > 0) { + try { + const apiResponse = await fetch('https://users.roblox.com/v1/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userIds: idsToFetch }), + }); + if (!apiResponse.ok) { + const errorData = await apiResponse.text(); + return NextResponse.json({ error: `Failed to fetch from Roblox API: ${errorData}` }, { status: apiResponse.status }); + } + const data = await apiResponse.json(); + for (const user of data.data || []) { + userInfoCache.set(user.id, { info: user, expires: now + USER_CACHE_TTL }); + result.push(user); + } + } catch { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } + } + + // Ensure result order matches input order + const ordered = userIds.map(id => { + return userInfoCache.get(id)?.info || cachedMap[id] || null; + }); + + const headers = new Headers(); + headers.set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); + return NextResponse.json({ data: ordered }, { headers }); } \ No newline at end of file -- 2.49.1 From dfe9107112c561ac992a04479854b206a33d3218 Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Tue, 1 Jul 2025 17:31:50 -0600 Subject: [PATCH 11/12] User information caching --- web/src/app/proxy/users/batch/RobloxUserInfo.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 web/src/app/proxy/users/batch/RobloxUserInfo.ts diff --git a/web/src/app/proxy/users/batch/RobloxUserInfo.ts b/web/src/app/proxy/users/batch/RobloxUserInfo.ts new file mode 100644 index 0000000..b422665 --- /dev/null +++ b/web/src/app/proxy/users/batch/RobloxUserInfo.ts @@ -0,0 +1,6 @@ +// Roblox user info type for batch endpoint +export interface RobloxUserInfo { + id: number; + name: string; + displayName: string; +} \ No newline at end of file -- 2.49.1 From 1f7ba5bb9b8acf10598cad321c409626f94fe6a6 Mon Sep 17 00:00:00 2001 From: ic3w0lf Date: Tue, 1 Jul 2025 17:57:06 -0600 Subject: [PATCH 12/12] Sorry to whoever uses 2 spaces instead of a tab --- .../_components/review/ReviewItemHeader.tsx | 2 +- web/src/app/maps/[mapId]/page.tsx | 837 +++++++++--------- 2 files changed, 434 insertions(+), 405 deletions(-) diff --git a/web/src/app/_components/review/ReviewItemHeader.tsx b/web/src/app/_components/review/ReviewItemHeader.tsx index 116e3ab..56d2d7e 100644 --- a/web/src/app/_components/review/ReviewItemHeader.tsx +++ b/web/src/app/_components/review/ReviewItemHeader.tsx @@ -16,7 +16,7 @@ interface ReviewItemHeaderProps { submitterUsername?: string; } -export const ReviewItemHeader = ({ displayName, statusId, creator, submitterId, submitterAvatarUrl, submitterUsername }: ReviewItemHeaderProps) => { +export const ReviewItemHeader = ({ displayName, assetId, statusId, creator, submitterId, submitterAvatarUrl, submitterUsername }: ReviewItemHeaderProps) => { const isProcessing = StatusMatches(statusId, [Status.Validating, Status.Uploading, Status.Submitting]); const pulse = keyframes` 0%, 100% { opacity: 0.2; transform: scale(0.8); } diff --git a/web/src/app/maps/[mapId]/page.tsx b/web/src/app/maps/[mapId]/page.tsx index 588d082..5f37a87 100644 --- a/web/src/app/maps/[mapId]/page.tsx +++ b/web/src/app/maps/[mapId]/page.tsx @@ -7,24 +7,25 @@ import React, { useState, useEffect } from "react"; import Link from "next/link"; import { Snackbar, Alert } from "@mui/material"; import { useBatchThumbnails } from "@/app/hooks/useBatchThumbnails"; +import { MapfixStatus, type MapfixInfo } from "@/app/ts/Mapfix"; // MUI Components import { - Typography, - Box, - Button, - Container, - Breadcrumbs, - Chip, - Grid, - Divider, - Paper, - Skeleton, - Stack, - CardMedia, - Tooltip, - IconButton, - CircularProgress + Typography, + Box, + Button, + Container, + Breadcrumbs, + Chip, + Grid, + Divider, + Paper, + Skeleton, + Stack, + CardMedia, + Tooltip, + IconButton, + CircularProgress } from "@mui/material"; import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; @@ -34,418 +35,446 @@ import BugReportIcon from "@mui/icons-material/BugReport"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; import DownloadIcon from '@mui/icons-material/Download'; +import LaunchIcon from '@mui/icons-material/Launch'; import {hasRole, RolesConstants} from "@/app/ts/Roles"; import {useTitle} from "@/app/hooks/useTitle"; export default function MapDetails() { - const { mapId } = useParams(); - const router = useRouter(); - const [map, setMap] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [copySuccess, setCopySuccess] = useState(false); - const [roles, setRoles] = useState(RolesConstants.Empty); - const [downloading, setDownloading] = useState(false); - const [mapfixes, setMapfixes] = useState([]); + const { mapId } = useParams(); + const router = useRouter(); + const [map, setMap] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [copySuccess, setCopySuccess] = useState(false); + const [roles, setRoles] = useState(RolesConstants.Empty); + const [downloading, setDownloading] = useState(false); + const [mapfixes, setMapfixes] = useState([]); - useTitle(map ? `${map.DisplayName}` : 'Loading Map...'); + useTitle(map ? `${map.DisplayName}` : 'Loading Map...'); - useEffect(() => { - async function getMap() { - try { - setLoading(true); - setError(null); - const res = await fetch(`/api/maps/${mapId}`); - if (!res.ok) { - throw new Error(`Failed to fetch map: ${res.status}`); - } - const data = await res.json(); - setMap(data); - } catch (error) { - console.error("Error fetching map details:", error); - setError(error instanceof Error ? error.message : "Failed to load map details"); - } finally { - setLoading(false); - } - } - getMap(); - }, [mapId]); - - useEffect(() => { - async function getRoles() { - 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); + useEffect(() => { + async function getMap() { + try { + setLoading(true); + setError(null); + const res = await fetch(`/api/maps/${mapId}`); + if (!res.ok) { + throw new Error(`Failed to fetch map: ${res.status}`); + } + const data = await res.json(); + setMap(data); + } catch (error) { + console.error("Error fetching map details:", error); + setError(error instanceof Error ? error.message : "Failed to load map details"); + } finally { + setLoading(false); + } } + getMap(); + }, [mapId]); + + useEffect(() => { + if (!map) return; + const targetAssetId = map.ID; + async function fetchMapfixes() { + try { + const limit = 100; + let page = 1; + let allMapfixes: MapfixInfo[] = []; + let total = 0; + do { + const res = await fetch(`/api/mapfixes?Page=${page}&Limit=${limit}&TargetAssetID=${targetAssetId}`); + if (!res.ok) break; + const data = await res.json(); + if (page === 1) total = data.Total; + allMapfixes = allMapfixes.concat(data.Mapfixes); + page++; + } while (allMapfixes.length < total); + // Filter out rejected, uploading, uploaded (StatusID > 7) + const active = allMapfixes.filter((fix: MapfixInfo) => fix.StatusID <= MapfixStatus.Validated); + setMapfixes(active); + } catch { + setMapfixes([]); + } + } + fetchMapfixes(); + }, [map]); + + useEffect(() => { + async function getRoles() { + 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); + } + } + getRoles() + }, [mapId]); + + // Use useBatchThumbnails for the map thumbnail + const assetIds = map?.ID ? [map.ID] : []; + const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds); + + const formatDate = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }; + + const getGameInfo = (gameId: number) => { + switch (gameId) { + case 1: + return { + name: "Bhop", + color: "#2196f3" // blue + }; + case 2: + return { + name: "Surf", + color: "#4caf50" // green + }; + case 5: + return { + name: "Fly Trials", + color: "#ff9800" // orange + }; + default: + return { + name: "Unknown", + color: "#9e9e9e" // gray + }; + } + }; + + const handleSubmitMapfix = () => { + router.push(`/maps/${mapId}/fix`); + }; + + const handleCopyId = (idToCopy: string) => { + navigator.clipboard.writeText(idToCopy); + setCopySuccess(true); + }; + + + const handleDownload = async () => { + setDownloading(true); + try { + // Fetch the download URL + const res = await fetch(`/api/maps/${mapId}/location`); + if (!res.ok) throw new Error('Failed to fetch download location'); + + const location = await res.text(); + + // open in new window + window.open(location.trim(), '_blank'); + + } catch (err) { + console.error('Download error:', err); + // Optional: Show user-friendly error message + alert('Download failed. Please try again.'); + } finally { + setDownloading(false); + } + }; + + const handleCloseSnackbar = () => { + setCopySuccess(false); + }; + + if (error) { + return ( + + + + Error Loading Map + {error} + + + + + ); } - getRoles() - }, [mapId]); - - // Use useBatchThumbnails for the map thumbnail - const assetIds = map?.ID ? [map.ID] : []; - const { thumbnails: thumbnailUrls } = useBatchThumbnails(assetIds); - const formatDate = (timestamp: number) => { - return new Date(timestamp * 1000).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - }); - }; + return ( + + + {/* Breadcrumbs Navigation */} + } + aria-label="breadcrumb" + sx={{ mb: 3 }} + > + + Home + + + Maps + + {loading ? "Loading..." : map?.DisplayName || "Map Details"} + + {loading ? ( + + + + + + + + + - const getGameInfo = (gameId: number) => { - switch (gameId) { - case 1: - return { - name: "Bhop", - color: "#2196f3" // blue - }; - case 2: - return { - name: "Surf", - color: "#4caf50" // green - }; - case 5: - return { - name: "Fly Trials", - color: "#ff9800" // orange - }; - default: - return { - name: "Unknown", - color: "#9e9e9e" // gray - }; - } - }; + + + + - const handleSubmitMapfix = () => { - router.push(`/maps/${mapId}/fix`); - }; + + + + + + + + + + ) : ( + map && ( + <> + {/* Map Header */} + + + + {map.DisplayName} + - const handleCopyId = (idToCopy: string) => { - navigator.clipboard.writeText(idToCopy); - setCopySuccess(true); - }; + {map.GameID && ( + + )} + + + + + + Created by: {map.Creator} + + - const handleDownload = async () => { - setDownloading(true); - try { - // Fetch the download URL - const res = await fetch(`/api/maps/${mapId}/location`); - if (!res.ok) throw new Error('Failed to fetch download location'); + + + + {formatDate(map.Date)} + + - const location = await res.text(); + + + + + ID: {mapId} + + + handleCopyId(mapId as string)} + sx={{ ml: 1 }} + > + + + + + + {!loading && hasRole(roles,RolesConstants.MapDownload) && ( + + + + Download + + + + + + + + )} + + - // open in new window - window.open(location.trim(), '_blank'); + + {/* Map Preview Section */} + + + {thumbnailUrls[map.ID] ? ( + + ) : ( + + + + )} + + - } catch (err) { - console.error('Download error:', err); - // Optional: Show user-friendly error message - alert('Download failed. Please try again.'); - } finally { - setDownloading(false); - } - }; + {/* Map Details Section */} + + + Map Details + - const handleCloseSnackbar = () => { - setCopySuccess(false); - }; + + + Display Name + {map.DisplayName} + - if (error) { - return ( - - - - Error Loading Map - {error} - - - - - ); - } + + Creator + {map.Creator} + - return ( - - - {/* Breadcrumbs Navigation */} - } - aria-label="breadcrumb" - sx={{ mb: 3 }} - > - - Home - - - Maps - - {loading ? "Loading..." : map?.DisplayName || "Map Details"} - - {loading ? ( - - - - - - - - - + + Game Type + {getGameInfo(map.GameID).name} + - - - - + + Release Date + {formatDate(map.Date)} + - - - - - - - - - - ) : ( - map && ( - <> - {/* Map Header */} - - - - {map.DisplayName} - + + Map ID + + {mapId} + + handleCopyId(mapId as string)} + sx={{ ml: 1, p: 0 }} + > + + + + + - {map.GameID && ( - - )} - + {/* Active Mapfix in Map Details */} + {mapfixes.length > 0 && (() => { + const active = mapfixes.find(fix => fix.StatusID <= MapfixStatus.Validated); + const latest = mapfixes.reduce((a, b) => (a.CreatedAt > b.CreatedAt ? a : b)); + const showFix = active || latest; + return ( + + + Active Mapfix + + + + {showFix.Description} + + + + + ); + })()} + + - - - - - Created by: {map.Creator} - - + + + + + + + ) + )} - - - - {formatDate(map.Date)} - - - - - - - - ID: {mapId} - - - handleCopyId(mapId as string)} - sx={{ ml: 1 }} - > - - - - - - {!loading && hasRole(roles,RolesConstants.MapDownload) && ( - - - - Download - - - - - - - - )} - - - - - {/* Map Preview Section */} - - - {thumbnailUrls[map.ID] ? ( - - ) : ( - - - - )} - - - - {/* Map Details Section */} - - - Map Details - - - - - Display Name - {map.DisplayName} - - - - Creator - {map.Creator} - - - - Game Type - {getGameInfo(map.GameID).name} - - - - Release Date - {formatDate(map.Date)} - - - - Map ID - - {mapId} - - handleCopyId(mapId as string)} - sx={{ ml: 1, p: 0 }} - > - - - - - - - {/* Active Mapfix in Map Details */} - {mapfixes.length > 0 && (() => { - const active = mapfixes.find(fix => fix.StatusID <= MapfixStatus.Validated); - const latest = mapfixes.reduce((a, b) => (a.CreatedAt > b.CreatedAt ? a : b)); - const showFix = active || latest; - return ( - - - Active Mapfix - - - - {showFix.Description} - - - - - ); - })()} - - - - - - - - - - ) - )} - - - - Map ID copied to clipboard! - - - - - ); + + + Map ID copied to clipboard! + + + + + ); } -- 2.49.1