mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 14:44:15 +00:00
feat: 'metadata' image generation for miis
for use on search engines
This commit is contained in:
parent
ada54d46c8
commit
de2c281257
22 changed files with 463 additions and 97 deletions
|
|
@ -14,7 +14,7 @@ import { prisma } from "@/lib/prisma";
|
|||
import { nameSchema, tagsSchema } from "@/lib/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
import { validateImage } from "@/lib/images";
|
||||
import { generateMetadataImage, validateImage } from "@/lib/images";
|
||||
import { convertQrCode } from "@/lib/qr-codes";
|
||||
import Mii from "@/lib/mii.js/mii";
|
||||
import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
|
||||
|
|
@ -41,7 +41,7 @@ export async function POST(request: NextRequest) {
|
|||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const response = await fetch(`${process.env.BASE_URL}/api/admin/can-submit`);
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
|
||||
const { value } = await response.json();
|
||||
if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409);
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
try {
|
||||
// Compress and upload
|
||||
// Compress and store
|
||||
const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer();
|
||||
const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp");
|
||||
|
||||
|
|
@ -152,16 +152,17 @@ export async function POST(request: NextRequest) {
|
|||
generatedCode.addData(byteString, "Byte");
|
||||
generatedCode.make();
|
||||
|
||||
// Upload QR code
|
||||
// Store QR code
|
||||
const codeDataUrl = generatedCode.createDataURL();
|
||||
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
|
||||
const codeBuffer = Buffer.from(codeBase64, "base64");
|
||||
|
||||
// Compress and upload
|
||||
// Compress and store
|
||||
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer();
|
||||
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
|
||||
|
||||
await fs.writeFile(codeFileLocation, codeWebpBuffer);
|
||||
await generateMetadataImage(miiRecord, session.user.username!);
|
||||
} catch (error) {
|
||||
// Clean up if something went wrong
|
||||
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
||||
|
|
@ -170,7 +171,7 @@ export async function POST(request: NextRequest) {
|
|||
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
|
||||
}
|
||||
|
||||
// Compress and upload user images
|
||||
// Compress and store user images
|
||||
try {
|
||||
await Promise.all(
|
||||
images.map(async (image, index) => {
|
||||
|
|
@ -192,7 +193,7 @@ export async function POST(request: NextRequest) {
|
|||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error uploading user images:", error);
|
||||
console.error("Error storing user images:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const lexend = Lexend({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.BASE_URL!),
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
|
||||
title: "TomodachiShare - home for Tomodachi Life Miis!",
|
||||
description: "Discover and share Mii residents for your Tomodachi Life island!",
|
||||
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection"],
|
||||
|
|
|
|||
|
|
@ -1,16 +1,30 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { idSchema } from "@/lib/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { generateMetadataImage } from "@/lib/images";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type MiiWithUser = Prisma.MiiGetPayload<{
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
type: z
|
||||
.enum(["mii", "qr-code", "image0", "image1", "image2"], {
|
||||
message: "Image type must be either 'mii', 'qr-code' or 'image[number from 0 to 2]'",
|
||||
.enum(["mii", "qr-code", "image0", "image1", "image2", "metadata"], {
|
||||
message: "Image type must be either 'mii', 'qr-code', 'image[number from 0 to 2]' or 'metadata'",
|
||||
})
|
||||
.default("mii"),
|
||||
});
|
||||
|
|
@ -29,12 +43,72 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||
if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.errors[0].message }, 400);
|
||||
const { type: imageType } = searchParamsParsed.data;
|
||||
|
||||
const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}.webp`);
|
||||
const fileExtension = imageType === "metadata" ? ".png" : ".webp";
|
||||
const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}${fileExtension}`);
|
||||
|
||||
let buffer: Buffer | undefined;
|
||||
// Only find Mii if image type is 'metadata'
|
||||
let mii: MiiWithUser | null = null;
|
||||
|
||||
if (imageType === "metadata") {
|
||||
mii = await prisma.mii.findUnique({
|
||||
where: {
|
||||
id: miiId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!mii) {
|
||||
return rateLimit.sendResponse({ error: "Mii not found" }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
return new NextResponse(buffer);
|
||||
// Try to read file
|
||||
buffer = await fs.readFile(filePath);
|
||||
} catch {
|
||||
return rateLimit.sendResponse({ error: "Image not found" }, 404);
|
||||
// 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.username!);
|
||||
|
||||
if (error) {
|
||||
return rateLimit.sendResponse({ error }, status);
|
||||
}
|
||||
|
||||
buffer = metadataBuffer;
|
||||
} else {
|
||||
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 username = slugify(mii.user.username!);
|
||||
|
||||
const filename = `${name}-mii-${tags}-by-${username}.png`;
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
"Content-Disposition": `inline; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(buffer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,13 +38,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
// Bots get redirected anyways
|
||||
if (!mii) return {};
|
||||
|
||||
const miiImageUrl = `/mii/${mii.id}/image?type=mii`;
|
||||
const qrCodeUrl = `/mii/${mii.id}/image?type=qr-code`;
|
||||
const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`;
|
||||
|
||||
const username = `@${mii.user.username}`;
|
||||
|
||||
return {
|
||||
metadataBase: new URL(process.env.BASE_URL!),
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
|
||||
title: `${mii.name} - TomodachiShare`,
|
||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
|
||||
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
|
||||
|
|
@ -53,7 +52,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
type: "article",
|
||||
title: `${mii.name} - TomodachiShare`,
|
||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
|
||||
images: [miiImageUrl, qrCodeUrl],
|
||||
images: [metadataImageUrl],
|
||||
publishedTime: mii.createdAt.toISOString(),
|
||||
authors: username,
|
||||
},
|
||||
|
|
@ -61,7 +60,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
card: "summary_large_image",
|
||||
title: `${mii.name} - TomodachiShare`,
|
||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
|
||||
images: [miiImageUrl, qrCodeUrl],
|
||||
images: [metadataImageUrl],
|
||||
creator: username,
|
||||
},
|
||||
alternates: {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export async function generateMetadata({ searchParams }: Props): Promise<Metadat
|
|||
const description = `Discover Miis tagged '${tags}' for your Tomodachi Life island!`;
|
||||
|
||||
return {
|
||||
metadataBase: new URL(process.env.BASE_URL!),
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
|
||||
title: `Miis tagged with '${tags}' - TomodachiShare`,
|
||||
description,
|
||||
keywords: [...tags, "mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection"],
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
});
|
||||
|
||||
return {
|
||||
metadataBase: new URL(process.env.BASE_URL!),
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
|
||||
title: `${user.name} (@${user.username}) - TomodachiShare`,
|
||||
description: `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`,
|
||||
keywords: ["mii", "tomodachi life", "nintendo", "mii creator", "mii collection", "profile"],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,6 @@ export default function robots(): MetadataRoute.Robots {
|
|||
allow: "/",
|
||||
disallow: ["/*?page", "/create-username", "/edit/*", "/profile/settings", "/random", "/submit", "/report/mii/*", "/report/user/*", "/admin"],
|
||||
},
|
||||
sitemap: `${process.env.BASE_URL}/sitemap.xml`,
|
||||
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import type { MetadataRoute } from "next";
|
|||
type SitemapRoute = MetadataRoute.Sitemap[0];
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.BASE_URL;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
||||
if (!baseUrl) {
|
||||
console.error("BASE_URL environment variable missing");
|
||||
console.error("NEXT_PUBLIC_BASE_URL environment variable missing");
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default async function SubmitPage() {
|
|||
if (!session.user.username) redirect("/create-username");
|
||||
|
||||
// Check if submissions are disabled
|
||||
const response = await fetch(`${process.env.BASE_URL}/api/admin/can-submit`);
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
|
||||
const { value } = await response.json();
|
||||
|
||||
if (!value)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue