diff --git a/backend/.env.example b/backend/.env.example index da79753..a6c854d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,7 +6,6 @@ REDIS_URL="redis://localhost:6379/0" # Used for metadata, sitemaps, etc. NEXT_PUBLIC_BASE_URL=http://localhost:3000 NEXT_PUBLIC_FRONTEND_URL=http://localhost:5173 -NEXT_PUBLIC_STATIC_URL= CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX diff --git a/backend/src/app/api/mii/[id]/edit/route.ts b/backend/src/app/api/mii/[id]/edit/route.ts index 7f1207f..d840838 100644 --- a/backend/src/app/api/mii/[id]/edit/route.ts +++ b/backend/src/app/api/mii/[id]/edit/route.ts @@ -246,8 +246,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ body: JSON.stringify({ files: [ `${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}`, - `${process.env.NEXT_PUBLIC_STATIC_URL}/mii/${miiId}/mii.png`, - `${process.env.NEXT_PUBLIC_STATIC_URL}/mii/${miiId}/features.png`, + `${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}/image?type=mii`, + `${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}/image?type=features`, ], }), }).catch((err) => { diff --git a/backend/src/app/mii/[id]/image/route.ts b/backend/src/app/mii/[id]/image/route.ts new file mode 100644 index 0000000..21b1782 --- /dev/null +++ b/backend/src/app/mii/[id]/image/route.ts @@ -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", + }); +} diff --git a/backend/src/app/profile/[id]/picture/route.ts b/backend/src/app/profile/[id]/picture/route.ts new file mode 100644 index 0000000..21b1782 --- /dev/null +++ b/backend/src/app/profile/[id]/picture/route.ts @@ -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", + }); +} diff --git a/backend/src/app/sitemap.ts b/backend/src/app/sitemap.ts index 19be510..5d57800 100644 --- a/backend/src/app/sitemap.ts +++ b/backend/src/app/sitemap.ts @@ -7,9 +7,8 @@ export const revalidate = 43200; // update every 12 hours export default async function sitemap(): Promise { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; - const staticUrl = process.env.NEXT_PUBLIC_STATIC_URL; - if (!baseUrl || !staticUrl) { - console.error("NEXT_PUBLIC_BASE_URL or NEXT_PUBLIC_STATIC_URL environment variable missing"); + if (!baseUrl) { + console.error("NEXT_PUBLIC_BASE_URL environment variable missing"); return []; } @@ -35,7 +34,7 @@ export default async function sitemap(): Promise { lastModified: mii.createdAt, changeFrequency: "weekly", priority: 0.7, - images: [`${staticUrl}/mii/${mii.id}/metadata.png`], + images: [`${baseUrl}/mii/${mii.id}/image?type=metadata`], }) as SitemapRoute, ), ...users.map( diff --git a/frontend/src/components/mii/delete-mii-button.tsx b/frontend/src/components/mii/delete-mii-button.tsx index 121bf39..199ae3b 100644 --- a/frontend/src/components/mii/delete-mii-button.tsx +++ b/frontend/src/components/mii/delete-mii-button.tsx @@ -85,7 +85,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr

Are you sure? This will delete your Mii permanently. This action cannot be undone.

- mii image + mii image

{miiName} diff --git a/frontend/src/components/mii/list/index.tsx b/frontend/src/components/mii/list/index.tsx index 770a3b1..d243c6f 100644 --- a/frontend/src/components/mii/list/index.tsx +++ b/frontend/src/components/mii/list/index.tsx @@ -126,7 +126,7 @@ export default function MiiList({ parentPage, userId }: Props) { {parentPage !== "admin" ? ( mii image {[ - `${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" - ? `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/qr-code.png` - : `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/features.png`, - ...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/image${i}.png`), + ? `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=qr-code` + : `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=features`, + ...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=image${i}`), ].map((src, i) => ( mii image ))} diff --git a/frontend/src/components/mii/share-mii-button.tsx b/frontend/src/components/mii/share-mii-button.tsx index eacefd3..fdcda5d 100644 --- a/frontend/src/components/mii/share-mii-button.tsx +++ b/frontend/src/components/mii/share-mii-button.tsx @@ -28,7 +28,7 @@ export default function ShareMiiButton({ miiId }: Props) { }; 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(); await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); @@ -118,7 +118,7 @@ export default function ShareMiiButton({ miiId }: Props) {

mii 'metadata' image {/* Save button */} { - 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 blob = await response.blob(); @@ -167,7 +167,6 @@ export default function EditMiiPage() { }, [mii, mii?.id, mii?.imageCount]); const API_URL = import.meta.env.VITE_API_URL; - const STATIC_URL = import.meta.env.VITE_STATIC_URL; useEffect(() => { fetch(`${API_URL}/api/mii/${id}/info`) @@ -182,8 +181,8 @@ export default function EditMiiPage() { setDescription(data.description); setGender(data.gender ?? "MALE"); setMakeup(data.makeup ?? "PARTIAL"); - setMiiPortraitUri(`${STATIC_URL}/mii/${data.id}/mii.png`); - setMiiFeaturesUri(`${STATIC_URL}/mii/${data.id}/features.png`); + setMiiPortraitUri(`${API_URL}/mii/${data.id}/image?type=mii`); + setMiiFeaturesUri(`${API_URL}/mii/${data.id}/image?type=features`); setYouTubeId(data.youtubeId ?? ""); setQuarantined(data.quarantined); instructions.current = deepMerge(defaultInstructions, (data.instructions as object) ?? {}); @@ -209,8 +208,10 @@ export default function EditMiiPage() {
URL.createObjectURL(file)), ]} /> diff --git a/frontend/src/pages/mii.tsx b/frontend/src/pages/mii.tsx index fa5f853..096b894 100644 --- a/frontend/src/pages/mii.tsx +++ b/frontend/src/pages/mii.tsx @@ -23,7 +23,6 @@ export default function MiiPage() { const [isLiked, setIsLiked] = useState(false); const API_URL = import.meta.env.VITE_API_URL; - const STATIC_URL = import.meta.env.VITE_STATIC_URL; useEffect(() => { fetch(`${API_URL}/api/mii/${id}/info`) @@ -51,7 +50,7 @@ export default function MiiPage() { }, [id]); if (loading || !mii) return
Loading...
; - 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 (
@@ -77,7 +76,7 @@ export default function MiiPage() { {/* Mii Image */}
) : (
- mii image + mii image

{mii.name}