Compare commits

...

10 commits

Author SHA1 Message Date
Yael Monterrubio
05c6212c7e
Merge 25dafcc24b into 7bd84ea454 2026-04-19 18:31:42 -06:00
7bd84ea454
fix: auth broken? 2026-04-19 23:38:10 +01:00
18a49a74e8
Merge pull request #41 from Y4ELX/fix-tag-links-on-mii-details-to-use-query-parameters
fix: update tag links to use query parameters
2026-04-19 22:21:00 +01:00
Yael Monterrubio
6211adc548 fix: update tag links to use query parameters 2026-04-19 15:17:19 -06:00
61cbdad812 fix: profile picture points to mii pictures
i copy pasted the wrong thing
2026-04-19 22:01:24 +01:00
7f87a42b11
Merge pull request #39 from Y4ELX/fix-infinite-loading-missing-resources
fix: handle not found errors for Mii and User in API responses
2026-04-19 21:58:14 +01:00
Yael Monterrubio
d5b488a5c9 fix: handle not found errors for Mii and User in API responses 2026-04-19 14:54:15 -06:00
8c12a835a6
Merge pull request #38 from Y4ELX/fix-profile-own-detection
fix: refactor user role checks in ProfileLayout
2026-04-19 21:48:55 +01:00
Yael Monterrubio
2bbc6d7c05 fix: refactor user role checks in ProfileLayout 2026-04-19 14:43:17 -06:00
90a65102c1 revert c951c7d7 2026-04-19 21:26:09 +01:00
15 changed files with 182 additions and 33 deletions

View file

@ -6,7 +6,6 @@ REDIS_URL="redis://localhost:6379/0"
# Used for metadata, sitemaps, etc. # Used for metadata, sitemaps, etc.
NEXT_PUBLIC_BASE_URL=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000
NEXT_PUBLIC_FRONTEND_URL=http://localhost:5173 NEXT_PUBLIC_FRONTEND_URL=http://localhost:5173
NEXT_PUBLIC_STATIC_URL=
CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX
CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX

View file

@ -246,8 +246,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
body: JSON.stringify({ body: JSON.stringify({
files: [ files: [
`${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}`, `${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}`,
`${process.env.NEXT_PUBLIC_STATIC_URL}/mii/${miiId}/mii.png`, `${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}/image?type=mii`,
`${process.env.NEXT_PUBLIC_STATIC_URL}/mii/${miiId}/features.png`, `${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}/image?type=features`,
], ],
}), }),
}).catch((err) => { }).catch((err) => {

View file

@ -34,5 +34,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}, },
}); });
if (!mii) return NextResponse.json({ error: "Mii not found" }, { status: 404 });
return NextResponse.json(mii); return NextResponse.json(mii);
} }

View file

@ -21,5 +21,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}, },
}); });
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
return NextResponse.json(user); return NextResponse.json(user);
} }

View file

@ -0,0 +1,115 @@
import { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import fs from "fs/promises";
import path from "path";
import { z } from "zod";
import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage } from "@/lib/images";
import { prisma } from "@/lib/prisma";
const searchParamsSchema = z.object({
type: z
.enum(["mii", "qr-code", "features", "image0", "image1", "image2", "metadata"], {
message: "Image type must be either 'mii', 'qr-code', 'features', 'image[number from 0 to 2]' or 'metadata'",
})
.default("mii"),
});
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 200, "/mii/image");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
const searchParamsParsed = searchParamsSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.issues[0].message }, 400);
const { type: imageType } = searchParamsParsed.data;
const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}.png`);
let buffer: Buffer | undefined;
// Only find Mii if image type is 'metadata'
let mii: Prisma.MiiGetPayload<{
include: {
user: {
select: {
name: true;
};
};
};
}> | null = null;
if (imageType === "metadata") {
mii = await prisma.mii.findUnique({
where: {
id: miiId,
},
include: {
user: {
select: {
name: true,
},
},
},
});
if (!mii) {
return rateLimit.sendResponse({ error: "Mii not found" }, 404);
}
}
try {
// Try to read file
buffer = await fs.readFile(filePath);
} catch {
// If the readFile() fails, that probably means it doesn't exist
if (imageType === "metadata" && mii) {
// Metadata images were added after 1274 Miis were submitted, so we generate it on-the-fly
console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`);
const { buffer: metadataBuffer, error, status } = await generateMetadataImage(mii, mii.user.name!);
if (error) {
return rateLimit.sendResponse({ error }, status);
}
buffer = metadataBuffer;
} else {
return rateLimit.sendResponse({ error: "Image not found" }, 404);
}
}
if (!buffer) return rateLimit.sendResponse({ error: "Image not found" }, 404);
// Set the file name for the metadata image in the response for SEO
if (mii && imageType === "metadata") {
const slugify = (str: string) =>
str
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-") // replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, "");
const name = slugify(mii.name);
const tags = mii.tags.map(slugify).join("-");
const filename = `${name}-mii-${tags}.png`;
return rateLimit.sendResponse(buffer, 200, {
"Content-Type": "image/png",
"Content-Disposition": `inline; filename="${filename}"`,
"Cache-Control": "public, max-age=31536000",
});
}
return rateLimit.sendResponse(buffer, 200, {
"Content-Type": "image/png",
"X-Robots-Tag": "noindex, noimageindex, nofollow",
"Cache-Control": "public, max-age=60, stale-while-revalidate=30",
});
}

View file

@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 16, "/profile/picture");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const userId = parsed.data;
const filePath = path.join(process.cwd(), "uploads", "user", `${userId}.png`);
try {
const buffer = await fs.readFile(filePath);
return new NextResponse(new Uint8Array(buffer)); // convert to Uint8Array due to weird types issue
} catch {
return rateLimit.sendResponse({ error: "Image not found" }, 404);
}
}

View file

@ -7,9 +7,8 @@ export const revalidate = 43200; // update every 12 hours
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
const staticUrl = process.env.NEXT_PUBLIC_STATIC_URL; if (!baseUrl) {
if (!baseUrl || !staticUrl) { console.error("NEXT_PUBLIC_BASE_URL environment variable missing");
console.error("NEXT_PUBLIC_BASE_URL or NEXT_PUBLIC_STATIC_URL environment variable missing");
return []; return [];
} }
@ -35,7 +34,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: mii.createdAt, lastModified: mii.createdAt,
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.7, priority: 0.7,
images: [`${staticUrl}/mii/${mii.id}/metadata.png`], images: [`${baseUrl}/mii/${mii.id}/image?type=metadata`],
}) as SitemapRoute, }) as SitemapRoute,
), ),
...users.map( ...users.map(

View file

@ -44,6 +44,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}, },
async redirect({ url, baseUrl }) { async redirect({ url, baseUrl }) {
if (url.startsWith(baseUrl)) return url;
return process.env.NEXT_PUBLIC_FRONTEND_URL ?? "http://localhost:4321"; return process.env.NEXT_PUBLIC_FRONTEND_URL ?? "http://localhost:4321";
}, },
}, },

View file

@ -85,7 +85,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
<p className="text-sm text-zinc-500">Are you sure? This will delete your Mii permanently. This action cannot be undone.</p> <p className="text-sm text-zinc-500">Are you sure? This will delete your Mii permanently. This action cannot be undone.</p>
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex overflow-hidden"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex overflow-hidden">
<img src={`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/mii.png`} alt="mii image" width={128} height={128} /> <img src={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4 min-w-0"> <div className="p-4 min-w-0">
<p className="text-xl font-bold line-clamp-3 wrap-anywhere" title={miiName}> <p className="text-xl font-bold line-clamp-3 wrap-anywhere" title={miiName}>
{miiName} {miiName}

View file

@ -126,7 +126,7 @@ export default function MiiList({ parentPage, userId }: Props) {
{parentPage !== "admin" ? ( {parentPage !== "admin" ? (
<Link to={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0"> <Link to={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
<img <img
src={`${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/mii.png`} src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`}
width={240} width={240}
height={160} height={160}
alt="mii image" alt="mii image"
@ -136,11 +136,11 @@ export default function MiiList({ parentPage, userId }: Props) {
) : ( ) : (
<div className="grid grid-cols-2 gap-1 rounded-xl bg-zinc-200"> <div className="grid grid-cols-2 gap-1 rounded-xl bg-zinc-200">
{[ {[
`${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/mii.png`, `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`,
mii.platform === "THREE_DS" mii.platform === "THREE_DS"
? `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/qr-code.png` ? `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=qr-code`
: `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/features.png`, : `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=features`,
...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/image${i}.png`), ...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=image${i}`),
].map((src, i) => ( ].map((src, i) => (
<img key={i} src={src} alt="mii image" className="w-full bg-zinc-200" /> <img key={i} src={src} alt="mii image" className="w-full bg-zinc-200" />
))} ))}

View file

@ -28,7 +28,7 @@ export default function ShareMiiButton({ miiId }: Props) {
}; };
const handleCopyImage = async () => { const handleCopyImage = async () => {
const response = await fetch(`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/metadata.png`); const response = await fetch(`${import.meta.env.VITE_BASE_URL}/mii/${miiId}/image?type=metadata`);
const blob = await response.blob(); const blob = await response.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
@ -118,7 +118,7 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg"> <div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
<img <img
src={`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/metadata.png`} src={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
alt="mii 'metadata' image" alt="mii 'metadata' image"
width={248} width={248}
height={248} height={248}
@ -130,7 +130,7 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex gap-2 w-full"> <div className="flex gap-2 w-full">
{/* Save button */} {/* Save button */}
<Link <Link
to={`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/metadata.png`} to={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl" className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
aria-label="Save Image" aria-label="Save Image"
data-tooltip="Save Image" data-tooltip="Save Image"

View file

@ -149,7 +149,7 @@ export default function EditMiiPage() {
try { try {
const existing = await Promise.all( const existing = await Promise.all(
Array.from({ length: mii.imageCount }, async (_, index) => { Array.from({ length: mii.imageCount }, async (_, index) => {
const path = `${STATIC_URL}/mii/${mii.id}/image${index}.png`; const path = `${API_URL}/mii/${mii.id}/image?type=image${index}`;
const response = await fetch(path); const response = await fetch(path);
const blob = await response.blob(); const blob = await response.blob();
@ -167,7 +167,6 @@ export default function EditMiiPage() {
}, [mii, mii?.id, mii?.imageCount]); }, [mii, mii?.id, mii?.imageCount]);
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
const STATIC_URL = import.meta.env.VITE_STATIC_URL;
useEffect(() => { useEffect(() => {
fetch(`${API_URL}/api/mii/${id}/info`) fetch(`${API_URL}/api/mii/${id}/info`)
@ -182,8 +181,8 @@ export default function EditMiiPage() {
setDescription(data.description); setDescription(data.description);
setGender(data.gender ?? "MALE"); setGender(data.gender ?? "MALE");
setMakeup(data.makeup ?? "PARTIAL"); setMakeup(data.makeup ?? "PARTIAL");
setMiiPortraitUri(`${STATIC_URL}/mii/${data.id}/mii.png`); setMiiPortraitUri(`${API_URL}/mii/${data.id}/image?type=mii`);
setMiiFeaturesUri(`${STATIC_URL}/mii/${data.id}/features.png`); setMiiFeaturesUri(`${API_URL}/mii/${data.id}/image?type=features`);
setYouTubeId(data.youtubeId ?? ""); setYouTubeId(data.youtubeId ?? "");
setQuarantined(data.quarantined); setQuarantined(data.quarantined);
instructions.current = deepMerge(defaultInstructions, (data.instructions as object) ?? {}); instructions.current = deepMerge(defaultInstructions, (data.instructions as object) ?? {});
@ -209,8 +208,10 @@ export default function EditMiiPage() {
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3"> <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel <Carousel
images={[ images={[
miiPortraitUri ?? `${STATIC_URL}/mii/${mii.id}/mii.png`, miiPortraitUri ?? `${API_URL}/mii/${mii.id}/image?type=mii`,
...(mii.platform === "THREE_DS" ? [`${STATIC_URL}/mii/${mii.id}/qr-code.png`] : [miiFeaturesUri ?? `${STATIC_URL}/mii/${mii.id}/features.png`]), ...(mii.platform === "THREE_DS"
? [`${API_URL}/mii/${mii.id}/image?type=qr-code`]
: [miiFeaturesUri ?? `${API_URL}/mii/${mii.id}/image?type=features`]),
...files.map((file) => URL.createObjectURL(file)), ...files.map((file) => URL.createObjectURL(file)),
]} ]}
/> />

View file

@ -23,7 +23,6 @@ export default function MiiPage() {
const [isLiked, setIsLiked] = useState(false); const [isLiked, setIsLiked] = useState(false);
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
const STATIC_URL = import.meta.env.VITE_STATIC_URL;
useEffect(() => { useEffect(() => {
fetch(`${API_URL}/api/mii/${id}/info`) fetch(`${API_URL}/api/mii/${id}/info`)
@ -32,6 +31,8 @@ export default function MiiPage() {
return res.json(); return res.json();
}) })
.then((data) => { .then((data) => {
if (!data) throw new Error("Mii not found");
setMii(data); setMii(data);
setLoading(false); setLoading(false);
@ -51,7 +52,7 @@ export default function MiiPage() {
}, [id]); }, [id]);
if (loading || !mii) return <div className="p-6 text-center">Loading...</div>; if (loading || !mii) return <div className="p-6 text-center">Loading...</div>;
const images = [...Array.from({ length: mii.imageCount ?? 0 }, (_, index) => `${STATIC_URL}/mii/${mii.id}/image${index}.png`)]; const images = [...Array.from({ length: mii.imageCount ?? 0 }, (_, index) => `${API_URL}/mii/${mii.id}/image?type=image${index}`)];
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@ -77,7 +78,7 @@ export default function MiiPage() {
{/* Mii Image */} {/* Mii Image */}
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center"> <div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<ImageViewer <ImageViewer
src={`${STATIC_URL}/mii/${mii.id}/mii.png`} src={`${API_URL}/mii/${mii.id}/image?type=mii`}
alt="mii headshot" alt="mii headshot"
width={250} width={250}
height={250} height={250}
@ -88,7 +89,7 @@ export default function MiiPage() {
{mii.platform === "THREE_DS" ? ( {mii.platform === "THREE_DS" ? (
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2"> <div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2">
<ImageViewer <ImageViewer
src={`${STATIC_URL}/mii/${mii.id}/qr-code.png`} src={`${API_URL}/mii/${mii.id}/image?type=qr-code`}
alt="mii qr code" alt="mii qr code"
width={128} width={128}
height={128} height={128}
@ -97,7 +98,7 @@ export default function MiiPage() {
</div> </div>
) : ( ) : (
<ImageViewer <ImageViewer
src={`${STATIC_URL}/mii/${mii.id}/features.png`} src={`${API_URL}/mii/${mii.id}/image?type=features`}
alt="mii features" alt="mii features"
width={300} width={300}
height={300} height={300}
@ -270,7 +271,7 @@ export default function MiiPage() {
{/* Tags */} {/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs"> <div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
{mii.tags.map((tag: string) => ( {mii.tags.map((tag: string) => (
<Link to={`/tags=${tag}`} key={tag}> <Link to={`/?tags=${encodeURIComponent(tag)}`} key={tag}>
{tag} {tag}
</Link> </Link>
))} ))}

View file

@ -28,6 +28,8 @@ export default function ProfileLayout() {
return res.json(); return res.json();
}) })
.then((data) => { .then((data) => {
if (!data) throw new Error("Profile not found");
setUser(data); setUser(data);
setLoading(false); setLoading(false);
}) })
@ -42,11 +44,11 @@ export default function ProfileLayout() {
return <div className="p-6 text-center">Loading...</div>; return <div className="p-6 text-center">Loading...</div>;
} }
const currentUser = user ?? $session?.user; const sessionUserId = $session?.user?.id ? Number($session.user.id) : null;
const page = location.pathname; const page = location.pathname;
const isAdmin = currentUser?.id === Number(import.meta.env.VITE_ADMIN_USER_ID); const isAdmin = sessionUserId === Number(import.meta.env.VITE_ADMIN_USER_ID);
const isContributor = import.meta.env.VITE_CONTRIBUTORS_USER_IDS?.split(",").includes(user?.id); const isContributor = import.meta.env.VITE_CONTRIBUTORS_USER_IDS?.split(",").includes(String(user?.id));
const isOwnProfile = currentUser?.id === user?.id; const isOwnProfile = sessionUserId === user?.id;
return ( return (
<div> <div>

View file

@ -64,7 +64,7 @@ export default function ReportMiiPage() {
<hr className="border-zinc-300" /> <hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<img src={`${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/mii.png`} alt="mii image" width={128} height={128} /> <img src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4"> <div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.name}</p> <p className="text-xl font-bold line-clamp-1">{mii.name}</p>
</div> </div>