Subtle visual changes, user avatar batching, rate-limiting, submitter name instead of author, ai comments
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
ic3w0lf
2025-06-30 16:47:19 -06:00
parent c21afaa846
commit a1e0e5f720
16 changed files with 394 additions and 47 deletions

View File

@@ -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 (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
src={event.User === validatorUser ? undefined : userAvatarUrl}
>
<PersonIcon />
</Avatar>

View File

@@ -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 (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar
src={event.User === validatorUser ? undefined : `/thumbnails/user/${event.User}`}
src={event.User === validatorUser ? undefined : userAvatarUrl}
>
<PersonIcon />
</Avatar>

View File

@@ -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 (
<Paper sx={{ p: 3, mt: 3 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
@@ -53,6 +58,7 @@ export default function CommentsAndAuditSection({
setNewComment={setNewComment}
handleCommentSubmit={handleCommentSubmit}
userId={userId}
commentUserAvatarUrls={commentUserAvatarUrls}
/>
<AuditEventsTabPanel

View File

@@ -18,6 +18,8 @@ interface CommentsTabPanelProps {
setNewComment: (comment: string) => void;
handleCommentSubmit: () => void;
userId: number | null;
userAvatarUrl?: string;
commentUserAvatarUrls?: Record<number, string>;
}
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 (
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Avatar
src={`/thumbnails/user/${userId}`}
src={userAvatarUrl}
/>
<TextField
fullWidth

View File

@@ -6,7 +6,8 @@ import {StatusChip} from "@/app/_components/statusChip";
interface MapCardProps {
displayName: string;
assetId: number;
authorId: number;
submitterId: number;
submitterUsername: string;
author: string;
rating: number;
id: number;
@@ -15,6 +16,7 @@ interface MapCardProps {
created: number;
type: 'mapfix' | 'submission';
thumbnailUrl?: string;
authorAvatarUrl?: string;
}
const CARD_WIDTH = 270;
@@ -152,37 +154,35 @@ export function MapCard(props: MapCardProps) {
</Typography>
</Box>
</Box>
<Box>
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar
src={`/thumbnails/user/${props.authorId}`}
alt={props.author}
sx={{
width: 24,
height: 24,
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
/>
<Typography
variant="caption"
sx={{
ml: 1,
color: 'text.secondary',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{/*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'
})}
</Typography>
</Box>
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar
src={props.authorAvatarUrl}
alt={props.submitterUsername}
sx={{
width: 24,
height: 24,
border: '1px solid rgba(255, 255, 255, 0.1)',
bgcolor: 'grey.900'
}}
/>
<Typography
variant="caption"
sx={{
ml: 1,
color: 'text.secondary',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{props.submitterUsername} - {new Date(props.created * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Typography>
</Box>
</CardContent>
</CardActionArea>

View File

@@ -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 */}

View File

@@ -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 }
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Avatar
src={`/thumbnails/user/${submitterId}`}
sx={{ mr: 1, width: 24, height: 24 }}
src={submitterAvatarUrl}
sx={{ mr: 1, width: 24, height: 24, border: '1px solid rgba(255, 255, 255, 0.1)', bgcolor: 'grey.900' }}
/>
<SubmitterName submitterId={submitterId} />
</Box>

View File

@@ -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<Record<number, string>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(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 };
}

View File

@@ -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<MapfixList | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [submitterUsernames, setSubmitterUsernames] = useState<Record<number, string>>({});
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<number, string> = {};
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 (
<Webpage>
@@ -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]}
/>
))}
</Box>

View File

@@ -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<SubmissionList | null>(null);
const [isLoadingMapfixes, setIsLoadingMapfixes] = useState<boolean>(false);
const [isLoadingSubmissions, setIsLoadingSubmissions] = useState<boolean>(false);
const [submissionUsernames, setSubmissionUsernames] = useState<Record<number, string>>({});
const [mapfixUsernames, setMapfixUsernames] = useState<Record<number, string>>({});
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<number, string> = {};
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<number, string> = {};
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]}
/>
);

View File

@@ -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) {

View File

@@ -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<SubmissionList | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [submitterUsernames, setSubmitterUsernames] = useState<Record<number, string>>({});
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<number, string> = {};
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 (
<Webpage>
@@ -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]}
/>
))}
</Box>

View File

@@ -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<number, { url: string, expires: number }>();
@@ -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) {

View File

@@ -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<number, { url: string; expires: number }>();
// 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' },

View File

@@ -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<number, { url: string, expires: number }>();
// 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<number, string | null> = {};
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);
}

32
web/src/lib/rateLimit.ts Normal file
View File

@@ -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<ip, { count: number, expires: number }>
const ipRateLimitMap = new Map<string, { count: number, expires: number }>();
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);
}