mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 06:34:15 +00:00
feat: astro test
This commit is contained in:
parent
df6e31ba89
commit
84144c383c
262 changed files with 18993 additions and 2655 deletions
29
backend/src/app/api/admin/accept-mii/route.ts
Normal file
29
backend/src/app/api/admin/accept-mii/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const parsedMiiId = idSchema.safeParse(searchParams.get("id"));
|
||||
|
||||
if (!parsedMiiId.success) return NextResponse.json({ error: parsedMiiId.error.issues[0].message }, { status: 400 });
|
||||
const miiId = parsedMiiId.data;
|
||||
|
||||
await prisma.mii.update({
|
||||
where: {
|
||||
id: miiId,
|
||||
},
|
||||
data: {
|
||||
in_queue: false,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
30
backend/src/app/api/admin/banner/route.ts
Normal file
30
backend/src/app/api/admin/banner/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
let bannerText: string | null = null;
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true, message: bannerText });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const body = await request.text();
|
||||
bannerText = body;
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
bannerText = null;
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
22
backend/src/app/api/admin/can-submit/route.ts
Normal file
22
backend/src/app/api/admin/can-submit/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { settings } from "@/lib/settings";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true, value: settings.canSubmit });
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const body = await request.json();
|
||||
const validated = z.boolean().safeParse(body);
|
||||
if (!validated.success) return NextResponse.json({ error: "Failed to validate body" }, { status: 400 });
|
||||
|
||||
settings.canSubmit = validated.data;
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
58
backend/src/app/api/admin/lookup/route.ts
Normal file
58
backend/src/app/api/admin/lookup/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const parsed = idSchema.safeParse(searchParams.get("id"));
|
||||
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
|
||||
const userId = parsed.data;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
punishments: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
returned: true,
|
||||
|
||||
notes: true,
|
||||
reasons: true,
|
||||
violatingMiis: {
|
||||
select: {
|
||||
miiId: true,
|
||||
reason: true,
|
||||
},
|
||||
},
|
||||
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return NextResponse.json({ error: "No user found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
createdAt: user.createdAt,
|
||||
punishments: user.punishments,
|
||||
});
|
||||
}
|
||||
87
backend/src/app/api/admin/punish/route.ts
Normal file
87
backend/src/app/api/admin/punish/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { z } from "zod";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { PunishmentType } from "@prisma/client";
|
||||
|
||||
const punishSchema = z.object({
|
||||
type: z.enum([PunishmentType.WARNING, PunishmentType.TEMP_EXILE, PunishmentType.PERM_EXILE]),
|
||||
duration: z
|
||||
.number({ error: "Duration (days) must be a number" })
|
||||
.int({ error: "Duration (days) must be an integer" })
|
||||
.positive({ error: "Duration (days) must be valid" }),
|
||||
notes: z.string(),
|
||||
reasons: z.array(z.string()).optional(),
|
||||
miiReasons: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number({ error: "Mii ID must be a number" }).int({ error: "Mii ID must be an integer" }).positive({ error: "Mii ID must be valid" }),
|
||||
reason: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const parsedUserId = idSchema.safeParse(searchParams.get("id"));
|
||||
|
||||
if (!parsedUserId.success) return NextResponse.json({ error: parsedUserId.error.issues[0].message }, { status: 400 });
|
||||
const userId = parsedUserId.data;
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = punishSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
|
||||
const { type, duration, notes, reasons, miiReasons } = parsed.data;
|
||||
|
||||
const expiresAt = type === "TEMP_EXILE" ? dayjs().add(duration, "days").toDate() : null;
|
||||
|
||||
await prisma.punishment.create({
|
||||
data: {
|
||||
userId,
|
||||
type: type as PunishmentType,
|
||||
expiresAt,
|
||||
notes,
|
||||
reasons: reasons?.length !== 0 ? reasons : [],
|
||||
violatingMiis: {
|
||||
create: miiReasons?.map((mii) => ({
|
||||
miiId: mii.id,
|
||||
reason: mii.reason,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const parsedPunishmentId = idSchema.safeParse(searchParams.get("id"));
|
||||
|
||||
if (!parsedPunishmentId.success) return NextResponse.json({ error: parsedPunishmentId.error.issues[0].message }, { status: 400 });
|
||||
const punishmentId = parsedPunishmentId.data;
|
||||
|
||||
await prisma.punishment.delete({
|
||||
where: {
|
||||
id: punishmentId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
22
backend/src/app/api/admin/queue/route.ts
Normal file
22
backend/src/app/api/admin/queue/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { settings } from "@/lib/settings";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true, value: settings.queueEnabled });
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const body = await request.json();
|
||||
const validated = z.boolean().safeParse(body);
|
||||
if (!validated.success) return NextResponse.json({ error: "Failed to validate body" }, { status: 400 });
|
||||
|
||||
settings.queueEnabled = validated.data;
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generateMetadataImage } from "@/lib/images";
|
||||
|
||||
export async function PATCH() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
// Start processing in background
|
||||
regenerateImages().catch(console.error);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
async function regenerateImages() {
|
||||
// Get miis in batches to reduce memory usage
|
||||
const BATCH_SIZE = 10;
|
||||
const totalMiis = await prisma.mii.count();
|
||||
let processed = 0;
|
||||
|
||||
for (let skip = 0; skip < totalMiis; skip += BATCH_SIZE) {
|
||||
const miis = await prisma.mii.findMany({
|
||||
skip,
|
||||
take: BATCH_SIZE,
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
|
||||
// Process each batch sequentially to avoid overwhelming the server
|
||||
for (const mii of miis) {
|
||||
try {
|
||||
await generateMetadataImage(mii, mii.user.name);
|
||||
processed++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate image for mii ${mii.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
backend/src/app/api/auth/[...nextauth]/route.ts
Normal file
3
backend/src/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
34
backend/src/app/api/auth/about-me/route.ts
Normal file
34
backend/src/app/api/auth/about-me/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { profanity } from "@2toad/profanity";
|
||||
import z from "zod";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 3);
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const { description } = await request.json();
|
||||
if (!description) return rateLimit.sendResponse({ error: "New about me is required" }, 400);
|
||||
|
||||
const validation = z.string().trim().max(256).safeParse(description);
|
||||
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: Number(session.user?.id) },
|
||||
data: { description: profanity.censor(description) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update description:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to update description" }, 500);
|
||||
}
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
}
|
||||
25
backend/src/app/api/auth/delete/route.ts
Normal file
25
backend/src/app/api/auth/delete/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 1);
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
where: { id: Number(session.user.id) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete user:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to delete account" }, 500);
|
||||
}
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
}
|
||||
37
backend/src/app/api/auth/name/route.ts
Normal file
37
backend/src/app/api/auth/name/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { profanity } from "@2toad/profanity";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { userNameSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 3);
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const { name } = await request.json();
|
||||
if (!name) return rateLimit.sendResponse({ error: "New name is required" }, 400);
|
||||
|
||||
const validation = userNameSchema.safeParse(name);
|
||||
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
|
||||
|
||||
// Check for inappropriate words
|
||||
if (profanity.exists(name)) return rateLimit.sendResponse({ error: "Name contains inappropriate words" }, 400);
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: Number(session.user.id) },
|
||||
data: { name },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update name:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to update name" }, 500);
|
||||
}
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
}
|
||||
85
backend/src/app/api/auth/picture/route.ts
Normal file
85
backend/src/app/api/auth/picture/route.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { validateImage } from "@/lib/images";
|
||||
|
||||
const uploadsDirectory = path.join(process.cwd(), "uploads", "user");
|
||||
|
||||
const formDataSchema = z.object({
|
||||
image: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 3);
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
// Check if profile picture was updated in the last 7 days
|
||||
const user = await prisma.user.findUnique({ where: { id: Number(session.user?.id) } });
|
||||
if (user && user.imageUpdatedAt) {
|
||||
const timePeriod = dayjs().subtract(7, "days");
|
||||
const lastUpdate = dayjs(user.imageUpdatedAt);
|
||||
|
||||
if (lastUpdate.isAfter(timePeriod)) return rateLimit.sendResponse({ error: "Profile picture was changed in the last 7 days" }, 400);
|
||||
}
|
||||
|
||||
// Parse data
|
||||
const formData = await request.formData();
|
||||
const parsed = formDataSchema.safeParse({
|
||||
image: formData.get("image"),
|
||||
});
|
||||
|
||||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||
const { image } = parsed.data;
|
||||
|
||||
// If there is no image, set the profile picture to the guest image
|
||||
if (!image) {
|
||||
await prisma.user.update({
|
||||
where: { id: Number(session.user?.id) },
|
||||
data: { image: `/guest.png`, imageUpdatedAt: new Date() },
|
||||
});
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
}
|
||||
|
||||
// Validate image contents
|
||||
const imageValidation = await validateImage(image);
|
||||
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
|
||||
|
||||
// Ensure directories exist
|
||||
await fs.mkdir(uploadsDirectory, { recursive: true });
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await image.arrayBuffer());
|
||||
const pngBuffer = await sharp(buffer, { animated: true }).resize({ width: 128, height: 128 }).png({ quality: 85 }).toBuffer();
|
||||
const fileLocation = path.join(uploadsDirectory, `${session.user?.id}.png`);
|
||||
|
||||
await fs.writeFile(fileLocation, pngBuffer);
|
||||
} catch (error) {
|
||||
console.error("Error uploading profile picture:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to store profile picture" }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: Number(session.user?.id) },
|
||||
data: { image: `/profile/${session.user?.id}/picture`, imageUpdatedAt: new Date() },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update profile picture:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to update profile picture" }, 500);
|
||||
}
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
}
|
||||
6
backend/src/app/api/auth/signin/[provider]/route.ts
Normal file
6
backend/src/app/api/auth/signin/[provider]/route.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { type NextRequest } from "next/server";
|
||||
import { signIn } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ provider: string }> }) {
|
||||
return signIn((await params).provider);
|
||||
}
|
||||
55
backend/src/app/api/mii/[id]/delete/route.ts
Normal file
55
backend/src/app/api/mii/[id]/delete/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 30, "/api/mii/delete");
|
||||
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;
|
||||
|
||||
// Check ownership of Mii
|
||||
const mii = await prisma.mii.findUnique({
|
||||
where: {
|
||||
id: miiId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
|
||||
if (!(Number(session.user?.id) === mii.userId || Number(session.user?.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)))
|
||||
return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403);
|
||||
|
||||
const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString());
|
||||
|
||||
try {
|
||||
await prisma.mii.delete({
|
||||
where: { id: miiId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete Mii from database:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to delete Mii" }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(miiUploadsDirectory, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn("Failed to delete Mii image files:", error);
|
||||
}
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
}
|
||||
258
backend/src/app/api/mii/[id]/edit/route.ts
Normal file
258
backend/src/app/api/mii/[id]/edit/route.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { MiiGender, MiiMakeup, Prisma } from "@prisma/client";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { profanity } from "@2toad/profanity";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema, nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { generateMetadataImage, validateImage } from "@/lib/images";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { minifyInstructions, SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import { settings } from "@/lib/settings";
|
||||
|
||||
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
||||
|
||||
const editSchema = z.object({
|
||||
name: nameSchema.optional(),
|
||||
tags: tagsSchema.optional(),
|
||||
description: z.string().trim().max(512).optional(),
|
||||
quarantined: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional(),
|
||||
gender: z.enum(MiiGender).optional(),
|
||||
makeup: z.enum(MiiMakeup).optional(),
|
||||
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
youtubeId: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID")
|
||||
.or(z.literal(""))
|
||||
.optional(),
|
||||
instructions: switchMiiInstructionsSchema,
|
||||
image1: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
image2: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
image3: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 6); // no grouped pathname; edit each mii 2 times a minute
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
// Get Mii ID
|
||||
const { id: slugId } = await params;
|
||||
const parsedId = idSchema.safeParse(slugId);
|
||||
if (!parsedId.success) return rateLimit.sendResponse({ error: parsedId.error.issues[0].message }, 400);
|
||||
const miiId = parsedId.data;
|
||||
|
||||
// Check ownership of Mii
|
||||
const mii = await prisma.mii.findUnique({
|
||||
where: {
|
||||
id: miiId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
|
||||
if (!(Number(session.user?.id) === mii.userId || Number(session.user?.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)))
|
||||
return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403);
|
||||
|
||||
// Parse form data
|
||||
const formData = await request.formData();
|
||||
|
||||
let rawTags: string[] | undefined = undefined;
|
||||
try {
|
||||
const value = formData.get("tags");
|
||||
if (value) rawTags = JSON.parse(value as string);
|
||||
} catch {
|
||||
return rateLimit.sendResponse({ error: "Invalid JSON in tags" }, 400);
|
||||
}
|
||||
|
||||
let minifiedInstructions: Partial<SwitchMiiInstructions> | undefined;
|
||||
if (mii.platform === "SWITCH")
|
||||
minifiedInstructions = minifyInstructions(JSON.parse((formData.get("instructions") as string) ?? "{}") as SwitchMiiInstructions);
|
||||
|
||||
const parsed = editSchema.safeParse({
|
||||
name: formData.get("name") ?? undefined,
|
||||
tags: rawTags,
|
||||
description: formData.get("description") ?? undefined,
|
||||
quarantined: formData.get("quarantined") ?? undefined,
|
||||
gender: formData.get("gender") ?? undefined,
|
||||
makeup: formData.get("makeup") ?? undefined,
|
||||
miiPortraitImage: formData.get("miiPortraitImage"),
|
||||
miiFeaturesImage: formData.get("miiFeaturesImage"),
|
||||
youtubeId: formData.get("youtubeId") ?? undefined,
|
||||
instructions: minifiedInstructions,
|
||||
image1: formData.get("image1"),
|
||||
image2: formData.get("image2"),
|
||||
image3: formData.get("image3"),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
const firstIssue = parsed.error.issues[0];
|
||||
const path = firstIssue.path.length ? firstIssue.path.join(".") : "root";
|
||||
const error = `${path}: ${firstIssue.message}`;
|
||||
return rateLimit.sendResponse({ error }, 400);
|
||||
}
|
||||
const { name, tags, description, quarantined, gender, makeup, miiPortraitImage, miiFeaturesImage, youtubeId, instructions, image1, image2, image3 } =
|
||||
parsed.data;
|
||||
|
||||
// Validate image files
|
||||
const customImages: File[] = [];
|
||||
|
||||
for (const img of [image1, image2, image3]) {
|
||||
if (!img) continue;
|
||||
|
||||
const validation = await validateImage(img);
|
||||
if (validation.valid) {
|
||||
customImages.push(img);
|
||||
} else {
|
||||
return rateLimit.sendResponse({ error: `Failed to verify custom image: ${validation.error}` }, validation.status ?? 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Mii portrait & features image (Switch)
|
||||
if (mii.platform === "SWITCH") {
|
||||
if (miiPortraitImage) {
|
||||
const validation = await validateImage(miiPortraitImage);
|
||||
if (!validation.valid) return rateLimit.sendResponse({ error: `Failed to verify portrait: ${validation.error}` }, validation.status ?? 400);
|
||||
}
|
||||
if (miiFeaturesImage) {
|
||||
const validation = await validateImage(miiFeaturesImage);
|
||||
if (!validation.valid) return rateLimit.sendResponse({ error: `Failed to verify features: ${validation.error}` }, validation.status ?? 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent non-admins from quarantining Miis
|
||||
if (quarantined && session.user?.id?.toString() !== process.env.NEXT_PUBLIC_ADMIN_USER_ID)
|
||||
return rateLimit.sendResponse({ error: `You're not an admin!` }, 401);
|
||||
|
||||
// Edit Mii in database
|
||||
const updateData: Prisma.MiiUpdateInput = {};
|
||||
if (name !== undefined) updateData.name = profanity.censor(name); // Censor potentially inappropriate words
|
||||
if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t));
|
||||
if (description !== undefined) updateData.description = profanity.censor(description);
|
||||
if (quarantined !== undefined) updateData.quarantined = quarantined;
|
||||
if (mii.platform === "SWITCH" && gender !== undefined) updateData.gender = gender;
|
||||
if (makeup !== undefined) updateData.makeup = makeup;
|
||||
if (youtubeId !== undefined) updateData.youtubeId = youtubeId;
|
||||
if (instructions !== undefined) updateData.instructions = instructions;
|
||||
if (customImages.length > 0) updateData.imageCount = customImages.length;
|
||||
|
||||
const imagesChanged = customImages.length > 0 || miiPortraitImage || miiFeaturesImage;
|
||||
if (settings.queueEnabled && imagesChanged) updateData.in_queue = true;
|
||||
|
||||
if (Object.keys(updateData).length === 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400);
|
||||
const updatedMii = await prisma.mii.update({
|
||||
where: {
|
||||
id: miiId,
|
||||
},
|
||||
data: updateData,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure directories exist
|
||||
const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString());
|
||||
await fs.mkdir(miiUploadsDirectory, { recursive: true });
|
||||
|
||||
// Only touch files if new images were uploaded
|
||||
if (customImages.length > 0) {
|
||||
// Delete all custom images
|
||||
const files = await fs.readdir(miiUploadsDirectory);
|
||||
await Promise.all(files.filter((file) => file.startsWith("image")).map((file) => fs.unlink(path.join(miiUploadsDirectory, file))));
|
||||
|
||||
// Compress and upload new images
|
||||
try {
|
||||
await Promise.all(
|
||||
customImages.map(async (image, index) => {
|
||||
const buffer = Buffer.from(await image.arrayBuffer());
|
||||
const pngBuffer = await sharp(buffer).resize({ height: 800, fit: "inside", withoutEnlargement: true }).png({ quality: 85 }).toBuffer();
|
||||
const fileLocation = path.join(miiUploadsDirectory, `image${index}.png`);
|
||||
|
||||
await fs.writeFile(fileLocation, pngBuffer);
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error uploading user images:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Only save portrait & features for Switch Miis when they are provided
|
||||
if (mii.platform === "SWITCH" && (miiPortraitImage || miiFeaturesImage)) {
|
||||
try {
|
||||
await Promise.all(
|
||||
[
|
||||
miiPortraitImage &&
|
||||
(async () => {
|
||||
const portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
|
||||
const pngBuffer = await sharp(portraitBuffer)
|
||||
.resize({
|
||||
height: 500,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.png({ quality: 85 })
|
||||
.toBuffer();
|
||||
await fs.writeFile(path.join(miiUploadsDirectory, "mii.png"), pngBuffer);
|
||||
})(),
|
||||
miiFeaturesImage &&
|
||||
(async () => {
|
||||
const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer());
|
||||
const pngBuffer = await sharp(featuresBuffer)
|
||||
.resize({
|
||||
height: 800,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.png({ quality: 85 })
|
||||
.toBuffer();
|
||||
await fs.writeFile(path.join(miiUploadsDirectory, "features.png"), pngBuffer);
|
||||
})(),
|
||||
].filter(Boolean),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error uploading portrait/features images:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to store portrait/features images" }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await generateMetadataImage(updatedMii, updatedMii.user.name!);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
|
||||
}
|
||||
|
||||
// Tell Cloudflare to purge cache for the changed pages
|
||||
fetch(`https://api.cloudflare.com/client/v4/zones/${process.env.CLOUDFLARE_ZONE_ID}/purge_cache`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
files: [
|
||||
`${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}`,
|
||||
`${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}/image?type=mii`,
|
||||
`${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}/image?type=features`,
|
||||
],
|
||||
}),
|
||||
}).catch((err) => {
|
||||
console.error("Cloudflare cache purge failed:", err);
|
||||
});
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
}
|
||||
38
backend/src/app/api/mii/[id]/info/route.ts
Normal file
38
backend/src/app/api/mii/[id]/info/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
const { id: slugId } = await params;
|
||||
const parsed = idSchema.safeParse(slugId);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
|
||||
const miiId = parsed.data;
|
||||
|
||||
const mii = await prisma.mii.findUnique({
|
||||
where: {
|
||||
id: miiId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
likedBy: session?.user
|
||||
? {
|
||||
where: {
|
||||
userId: Number(session.user.id),
|
||||
},
|
||||
select: { userId: true },
|
||||
}
|
||||
: false,
|
||||
_count: {
|
||||
select: { likedBy: true }, // Get total like count
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(mii);
|
||||
}
|
||||
59
backend/src/app/api/mii/[id]/like/route.ts
Normal file
59
backend/src/app/api/mii/[id]/like/route.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 100, "/api/mii/like");
|
||||
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 result = await prisma.$transaction(async (tx) => {
|
||||
const existingLike = await tx.like.findUnique({
|
||||
where: {
|
||||
userId_miiId: {
|
||||
userId: Number(session.user?.id),
|
||||
miiId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingLike) {
|
||||
// Remove the like if it exists
|
||||
await tx.like.delete({
|
||||
where: {
|
||||
userId_miiId: {
|
||||
userId: Number(session.user?.id),
|
||||
miiId,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Add a like if it doesn't exist
|
||||
await tx.like.create({
|
||||
data: {
|
||||
userId: Number(session.user?.id),
|
||||
miiId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const likeCount = await tx.like.count({
|
||||
where: { miiId },
|
||||
});
|
||||
|
||||
return { liked: !existingLike, count: likeCount };
|
||||
});
|
||||
|
||||
return rateLimit.sendResponse({ success: true, liked: result.liked, count: result.count });
|
||||
}
|
||||
28
backend/src/app/api/mii/has-liked/route.ts
Normal file
28
backend/src/app/api/mii/has-liked/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 50, "/api/mii/like_get");
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const idsParam = new URL(request.url).searchParams.get("ids");
|
||||
if (!idsParam) return NextResponse.json({ error: "Missing IDs parameter" }, { status: 400 });
|
||||
|
||||
const ids = idsParam.split(",").map(Number).filter(Boolean);
|
||||
if (!ids.length) return NextResponse.json({ error: "No valid IDs provided" }, { status: 400 });
|
||||
if (ids.length > 100) return NextResponse.json({ error: "Too many IDs, maximum is 100" }, { status: 400 });
|
||||
|
||||
const liked = await prisma.like.findMany({
|
||||
where: { userId: Number(session.user?.id), miiId: { in: ids } },
|
||||
select: { miiId: true },
|
||||
});
|
||||
|
||||
// Return only Miis that are liked
|
||||
return NextResponse.json(liked.map((l) => l.miiId));
|
||||
}
|
||||
168
backend/src/app/api/mii/list/route.ts
Normal file
168
backend/src/app/api/mii/list/route.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { searchSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import crypto from "crypto";
|
||||
import seedrandom from "seedrandom";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
|
||||
|
||||
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed, parentPage, userId } = parsed.data;
|
||||
|
||||
// My Likes page
|
||||
let miiIdsLiked: number[] | undefined = undefined;
|
||||
|
||||
if (parentPage === "likes" && session?.user?.id) {
|
||||
const likedMiis = await prisma.like.findMany({
|
||||
where: { userId: Number(session.user.id) },
|
||||
select: { miiId: true },
|
||||
});
|
||||
miiIdsLiked = likedMiis.map((like) => like.miiId);
|
||||
}
|
||||
|
||||
const where: Prisma.MiiWhereInput = {
|
||||
// In queue logic
|
||||
...(parentPage === "admin"
|
||||
? { in_queue: true } // Only show queued Miis
|
||||
: userId
|
||||
? {
|
||||
// Include queued Miis if user is on their profile
|
||||
...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
|
||||
userId,
|
||||
}
|
||||
: {
|
||||
// Don't show queued Miis on main page
|
||||
in_queue: false,
|
||||
}),
|
||||
// Only show liked miis on likes page
|
||||
...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
|
||||
// Searching
|
||||
...(query && {
|
||||
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
|
||||
}),
|
||||
// Tag filtering
|
||||
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
|
||||
// Platform
|
||||
...(platform && { platform: { equals: platform } }),
|
||||
// Gender
|
||||
...(gender && { gender: { equals: gender } }),
|
||||
// Allow Copying
|
||||
...(allowCopying && { allowedCopying: true }),
|
||||
// Makeup
|
||||
...(makeup && { makeup: { equals: makeup } }),
|
||||
// Quarantined
|
||||
...(!quarantined && !userId && { quarantined: false }),
|
||||
};
|
||||
|
||||
const select: Prisma.MiiSelect = {
|
||||
id: true,
|
||||
// Don't show when userId is specified
|
||||
...(!userId && {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
platform: true,
|
||||
name: true,
|
||||
imageCount: true,
|
||||
tags: true,
|
||||
createdAt: true,
|
||||
gender: true,
|
||||
makeup: true,
|
||||
allowedCopying: true,
|
||||
quarantined: true,
|
||||
in_queue: true,
|
||||
// Mii liked check
|
||||
...(session?.user?.id && {
|
||||
likedBy: {
|
||||
where: { userId: Number(session.user.id) },
|
||||
select: { userId: true },
|
||||
},
|
||||
}),
|
||||
// Like count
|
||||
_count: {
|
||||
select: { likedBy: true },
|
||||
},
|
||||
};
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
let totalCount: number;
|
||||
let filteredCount: number;
|
||||
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||
|
||||
if (sort === "random") {
|
||||
// Get all IDs that match the where conditions
|
||||
const matchingIds = await prisma.mii.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
totalCount = matchingIds.length;
|
||||
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
|
||||
|
||||
if (matchingIds.length === 0) return;
|
||||
|
||||
// Use seed for consistent random results
|
||||
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||
const rng = seedrandom(randomSeed.toString());
|
||||
|
||||
// Randomize all IDs using the Durstenfeld algorithm
|
||||
for (let i = matchingIds.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
|
||||
}
|
||||
|
||||
// Convert to number[] array
|
||||
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
|
||||
|
||||
miis = await prisma.mii.findMany({
|
||||
where: {
|
||||
id: { in: selectedIds },
|
||||
},
|
||||
select,
|
||||
});
|
||||
} else {
|
||||
// Sorting by likes, newest, or oldest
|
||||
let orderBy: Prisma.MiiOrderByWithRelationInput[];
|
||||
|
||||
if (sort === "likes") {
|
||||
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
|
||||
} else if (sort === "oldest") {
|
||||
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
|
||||
} else {
|
||||
// default to newest
|
||||
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
|
||||
}
|
||||
|
||||
[totalCount, filteredCount, miis] = await Promise.all([
|
||||
prisma.mii.count({ where: { ...where } }), // TODO: User id
|
||||
prisma.mii.count({ where, skip, take: limit }),
|
||||
prisma.mii.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
select,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
const lastPage = Math.ceil(totalCount / limit);
|
||||
|
||||
return NextResponse.json({
|
||||
miis,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
lastPage,
|
||||
});
|
||||
}
|
||||
25
backend/src/app/api/profile/[id]/info/route.ts
Normal file
25
backend/src/app/api/profile/[id]/info/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: slugId } = await params;
|
||||
const parsed = idSchema.safeParse(slugId);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
|
||||
const userId = parsed.data;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
likes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(user);
|
||||
}
|
||||
106
backend/src/app/api/report/route.ts
Normal file
106
backend/src/app/api/report/route.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { Prisma, ReportReason, ReportType } from "@prisma/client";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const reportSchema = z.object({
|
||||
id: z.coerce.number({ error: "ID must be a number" }).int({ error: "ID must be an integer" }).positive({ error: "ID must be valid" }),
|
||||
type: z.enum(["mii", "user"], { error: "Type must be either 'mii' or 'user'" }),
|
||||
reason: z.enum(["inappropriate", "spam", "bad_quality", "other"], {
|
||||
message: "Reason must be either 'inappropriate', 'spam', 'bad_quality' or 'other'",
|
||||
}),
|
||||
notes: z.string().trim().max(256).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 2);
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = reportSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||
const { id, type, reason, notes } = parsed.data;
|
||||
|
||||
let mii: Prisma.MiiGetPayload<{
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}> | null = null;
|
||||
|
||||
// Check if the Mii or User exists
|
||||
if (type === "mii") {
|
||||
mii = await prisma.mii.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
|
||||
} else {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!user) return rateLimit.sendResponse({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
// Check if user creating the report has already reported the same target before
|
||||
const existing = await prisma.report.findFirst({
|
||||
where: {
|
||||
targetId: id,
|
||||
reportType: type.toUpperCase() as ReportType,
|
||||
authorId: Number(session.user?.id),
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) return rateLimit.sendResponse({ error: "You have already reported this" }, 400);
|
||||
|
||||
try {
|
||||
await prisma.report.create({
|
||||
data: {
|
||||
reportType: type.toUpperCase() as ReportType,
|
||||
targetId: id,
|
||||
reason: reason.toUpperCase() as ReportReason,
|
||||
reasonNotes: notes,
|
||||
authorId: Number(session.user?.id),
|
||||
creatorId: mii ? mii.userId : undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Report creation failed", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to create report" }, 500);
|
||||
}
|
||||
|
||||
// Send notification to ntfy
|
||||
if (process.env.NTFY_URL) {
|
||||
// This is only shown if report type is MII
|
||||
const miiCreatorMessage = mii ? `by ${mii.user.name} (ID: ${mii.userId})` : "";
|
||||
|
||||
await fetch(process.env.NTFY_URL, {
|
||||
method: "POST",
|
||||
body: `Report by ${session.user?.name} (ID: ${session.user?.id}) on ${type.toUpperCase()} (ID: ${id}) ${miiCreatorMessage}`,
|
||||
headers: {
|
||||
Title: "Report recieved - TomodachiShare",
|
||||
Priority: "urgent",
|
||||
Tags: "triangular_flag_on_post",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
}
|
||||
48
backend/src/app/api/return/route.ts
Normal file
48
backend/src/app/api/return/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 1);
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const activePunishment = await prisma.punishment.findFirst({
|
||||
where: {
|
||||
userId: Number(session.user?.id),
|
||||
returned: false,
|
||||
},
|
||||
include: {
|
||||
violatingMiis: {
|
||||
include: {
|
||||
mii: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!activePunishment) return rateLimit.sendResponse({ error: "You have no active punishments!" }, 404);
|
||||
if (activePunishment.type === "PERM_EXILE") return rateLimit.sendResponse({ error: "Your punishment is permanent" }, 403);
|
||||
if (activePunishment.type === "TEMP_EXILE" && activePunishment.expiresAt! > new Date())
|
||||
return rateLimit.sendResponse({ error: "Your punishment has not expired yet." }, 403);
|
||||
|
||||
await prisma.punishment.update({
|
||||
where: {
|
||||
id: activePunishment.id,
|
||||
},
|
||||
data: {
|
||||
returned: true,
|
||||
},
|
||||
});
|
||||
|
||||
return rateLimit.sendResponse({ success: true });
|
||||
}
|
||||
326
backend/src/app/api/submit/route.ts
Normal file
326
backend/src/app/api/submit/route.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
|
||||
import qrcode from "qrcode-generator";
|
||||
import { profanity } from "@2toad/profanity";
|
||||
import { MiiGender, MiiMakeup, MiiPlatform } from "@prisma/client";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { generateMetadataImage, validateImage } from "@/lib/images";
|
||||
import Mii from "../../../../../shared/src/mii.js/mii";
|
||||
import { convertQrCode, minifyInstructions, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared";
|
||||
|
||||
import { SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import { settings } from "@/lib/settings";
|
||||
|
||||
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
||||
|
||||
const submitSchema = z
|
||||
.object({
|
||||
platform: z.enum(MiiPlatform).default("THREE_DS"),
|
||||
name: nameSchema,
|
||||
tags: tagsSchema,
|
||||
description: z.string().trim().max(512).optional(),
|
||||
|
||||
// Switch
|
||||
gender: z.enum(MiiGender).default("MALE"),
|
||||
makeup: z.enum(MiiMakeup).default("PARTIAL"),
|
||||
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
youtubeId: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) => (val === "" ? null : val))
|
||||
.refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID")
|
||||
.optional(),
|
||||
instructions: switchMiiInstructionsSchema,
|
||||
|
||||
// QR code
|
||||
qrBytesRaw: z
|
||||
.array(z.number(), { error: "A QR code is required" })
|
||||
.length(372, {
|
||||
error: "QR code size is not a valid Tomodachi Life QR code",
|
||||
})
|
||||
.nullish(),
|
||||
|
||||
// Custom images
|
||||
image1: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
image2: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
image3: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
})
|
||||
// This refine function is probably useless
|
||||
.refine(
|
||||
(data) => {
|
||||
// If platform is Switch, gender, miiPortraitImage, and miiFeaturesImage must be present
|
||||
if (data.platform === "SWITCH") {
|
||||
return data.gender !== undefined && data.miiPortraitImage !== undefined && data.miiFeaturesImage !== undefined;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Gender, Mii portrait & features image are required for Switch platform",
|
||||
path: ["gender", "miiPortraitImage", "miiFeaturesImage"],
|
||||
},
|
||||
);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const rateLimit = new RateLimit(request, 3);
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
if (!settings.canSubmit) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503);
|
||||
|
||||
// Parse tags and QR code as JSON
|
||||
const formData = await request.formData();
|
||||
|
||||
let rawTags: string[];
|
||||
let rawQrBytesRaw: string[]; // raw raw
|
||||
try {
|
||||
rawTags = JSON.parse(formData.get("tags") as string);
|
||||
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
|
||||
} catch (error) {
|
||||
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
|
||||
}
|
||||
|
||||
// Minify instructions to save space and improve user experience
|
||||
let minifiedInstructions: Partial<SwitchMiiInstructions> | undefined;
|
||||
if (formData.get("platform") === "SWITCH")
|
||||
minifiedInstructions = minifyInstructions(JSON.parse((formData.get("instructions") as string) ?? "{}") as SwitchMiiInstructions);
|
||||
|
||||
// Parse and check all submission info
|
||||
const parsed = submitSchema.safeParse({
|
||||
platform: formData.get("platform"),
|
||||
name: formData.get("name"),
|
||||
tags: rawTags,
|
||||
description: formData.get("description"),
|
||||
|
||||
gender: formData.get("gender") ?? undefined, // ZOD MOMENT
|
||||
makeup: formData.get("makeup") ?? undefined,
|
||||
miiPortraitImage: formData.get("miiPortraitImage"),
|
||||
miiFeaturesImage: formData.get("miiFeaturesImage"),
|
||||
youtubeId: formData.get("youtubeId"),
|
||||
instructions: minifiedInstructions,
|
||||
|
||||
qrBytesRaw: rawQrBytesRaw,
|
||||
|
||||
image1: formData.get("image1"),
|
||||
image2: formData.get("image2"),
|
||||
image3: formData.get("image3"),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
const firstIssue = parsed.error.issues[0];
|
||||
const path = firstIssue.path.length ? firstIssue.path.join(".") : "root";
|
||||
const error = `${path}: ${firstIssue.message}`;
|
||||
return rateLimit.sendResponse({ error }, 400);
|
||||
}
|
||||
const {
|
||||
platform,
|
||||
name: uncensoredName,
|
||||
tags: uncensoredTags,
|
||||
description: uncensoredDescription,
|
||||
qrBytesRaw,
|
||||
gender,
|
||||
makeup,
|
||||
miiPortraitImage,
|
||||
miiFeaturesImage,
|
||||
youtubeId,
|
||||
image1,
|
||||
image2,
|
||||
image3,
|
||||
} = parsed.data;
|
||||
|
||||
// Censor potential inappropriate words
|
||||
const name = profanity.censor(uncensoredName);
|
||||
const tags = uncensoredTags.map((t) => profanity.censor(t));
|
||||
const description = uncensoredDescription && profanity.censor(uncensoredDescription);
|
||||
|
||||
// Validate image files
|
||||
const customImages: File[] = [];
|
||||
|
||||
for (const img of [image1, image2, image3]) {
|
||||
if (!img) continue;
|
||||
|
||||
const validation = await validateImage(img);
|
||||
if (validation.valid) {
|
||||
customImages.push(img);
|
||||
} else {
|
||||
return rateLimit.sendResponse({ error: `Failed to verify custom image: ${validation.error}` }, validation.status ?? 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Mii portrait & features image (Switch)
|
||||
if (platform === "SWITCH") {
|
||||
const portraitValidation = await validateImage(miiPortraitImage);
|
||||
const featuresValidation = await validateImage(miiFeaturesImage);
|
||||
if (!portraitValidation.valid)
|
||||
return rateLimit.sendResponse({ error: `Failed to verify portrait: ${portraitValidation.error}` }, portraitValidation.status ?? 400);
|
||||
if (!featuresValidation.valid)
|
||||
return rateLimit.sendResponse({ error: `Failed to verify features: ${featuresValidation.error}` }, featuresValidation.status ?? 400);
|
||||
}
|
||||
|
||||
const qrBytes = new Uint8Array(qrBytesRaw ?? []);
|
||||
|
||||
// Convert QR code to JS (3DS)
|
||||
let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii } | undefined;
|
||||
if (platform === "THREE_DS") {
|
||||
try {
|
||||
conversion = convertQrCode(qrBytes);
|
||||
} catch (error) {
|
||||
return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Mii in database
|
||||
const miiRecord = await prisma.mii.create({
|
||||
data: {
|
||||
userId: Number(session.user?.id),
|
||||
platform,
|
||||
name,
|
||||
tags,
|
||||
description,
|
||||
gender: gender ?? "MALE",
|
||||
in_queue: settings.queueEnabled,
|
||||
|
||||
// Automatically detect certain information if on 3DS
|
||||
...(platform === "THREE_DS"
|
||||
? conversion && {
|
||||
firstName: conversion.tomodachiLifeMii.firstName,
|
||||
lastName: conversion.tomodachiLifeMii.lastName,
|
||||
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE,
|
||||
islandName: conversion.tomodachiLifeMii.islandName,
|
||||
allowedCopying: conversion.mii.allowCopying,
|
||||
}
|
||||
: {
|
||||
youtubeId,
|
||||
instructions: minifiedInstructions,
|
||||
makeup: makeup ?? "PARTIAL",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure directories exist
|
||||
const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString());
|
||||
await fs.mkdir(miiUploadsDirectory, { recursive: true });
|
||||
|
||||
try {
|
||||
let portraitBuffer: Buffer | undefined;
|
||||
|
||||
// Download the image of the Mii (3DS)
|
||||
if (platform === "THREE_DS") {
|
||||
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
|
||||
const studioResponse = await fetch(studioUrl!);
|
||||
|
||||
if (!studioResponse.ok) {
|
||||
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
|
||||
}
|
||||
|
||||
portraitBuffer = Buffer.from(await studioResponse.arrayBuffer());
|
||||
} else if (platform === "SWITCH") {
|
||||
portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
|
||||
|
||||
// Save features image
|
||||
const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer());
|
||||
const pngBuffer = await sharp(featuresBuffer)
|
||||
.resize({
|
||||
height: 800,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.png({ quality: 85 })
|
||||
.toBuffer();
|
||||
const fileLocation = path.join(miiUploadsDirectory, "features.png");
|
||||
await fs.writeFile(fileLocation, pngBuffer);
|
||||
}
|
||||
|
||||
// Save portrait image
|
||||
if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
|
||||
const pngBuffer = await sharp(portraitBuffer)
|
||||
.resize({
|
||||
height: 500,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.png({ quality: 85 })
|
||||
.toBuffer();
|
||||
const fileLocation = path.join(miiUploadsDirectory, "mii.png");
|
||||
|
||||
await fs.writeFile(fileLocation, pngBuffer);
|
||||
} catch (error) {
|
||||
// Clean up if something went wrong
|
||||
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
||||
|
||||
console.error("Failed to download/store Mii portrait/features:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait/features" }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
await generateMetadataImage(miiRecord, session.user?.name!);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate metadata image:", error);
|
||||
}
|
||||
|
||||
if (platform === "THREE_DS") {
|
||||
try {
|
||||
// Generate a new QR code for aesthetic reasons
|
||||
const byteString = String.fromCharCode(...qrBytes);
|
||||
const generatedCode = qrcode(0, "L");
|
||||
generatedCode.addData(byteString, "Byte");
|
||||
generatedCode.make();
|
||||
|
||||
// Store QR code
|
||||
const codeDataUrl = generatedCode.createDataURL();
|
||||
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
|
||||
const codeBuffer = Buffer.from(codeBase64, "base64");
|
||||
|
||||
// Compress and store
|
||||
const codePngBuffer = await sharp(codeBuffer).png({ quality: 85 }).toBuffer();
|
||||
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.png");
|
||||
|
||||
await fs.writeFile(codeFileLocation, codePngBuffer);
|
||||
} catch (error) {
|
||||
// Clean up if something went wrong
|
||||
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
||||
|
||||
console.error("Error processing Mii files:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Compress and store user images
|
||||
try {
|
||||
await Promise.all(
|
||||
customImages.map(async (image, index) => {
|
||||
const buffer = Buffer.from(await image.arrayBuffer());
|
||||
const pngBuffer = await sharp(buffer).resize({ height: 800, fit: "inside", withoutEnlargement: true }).png({ quality: 85 }).toBuffer();
|
||||
const fileLocation = path.join(miiUploadsDirectory, `image${index}.png`);
|
||||
|
||||
await fs.writeFile(fileLocation, pngBuffer);
|
||||
}),
|
||||
);
|
||||
|
||||
// Update database to tell it how many images exist
|
||||
await prisma.mii.update({
|
||||
where: {
|
||||
id: miiRecord.id,
|
||||
},
|
||||
data: {
|
||||
imageCount: customImages.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error storing user images:", error);
|
||||
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
|
||||
}
|
||||
|
||||
return rateLimit.sendResponse({ success: true, id: miiRecord.id });
|
||||
}
|
||||
50
backend/src/app/edit/[id]/page.tsx
Normal file
50
backend/src/app/edit/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import EditForm from "@/components/submit-form/edit-form";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
|
||||
const mii = await prisma.mii.findUnique({
|
||||
where: {
|
||||
id: Number(id),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
title: `${mii?.name} - TomodachiShare`,
|
||||
description: `Edit the name, tags, and images of '${mii?.name}'`,
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MiiPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
const mii = await prisma.mii.findUnique({
|
||||
where: {
|
||||
id: Number(id),
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { likedBy: true }, // Get total like count
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check ownership
|
||||
if (!mii || (Number(session?.user?.id) !== mii.userId && Number(session?.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) redirect("/404");
|
||||
|
||||
return <EditForm mii={mii} likes={mii._count.likedBy} />;
|
||||
}
|
||||
115
backend/src/app/mii/[id]/image/route.ts
Normal file
115
backend/src/app/mii/[id]/image/route.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
122
backend/src/app/off-the-island/page.tsx
Normal file
122
backend/src/app/off-the-island/page.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// import ReturnToIsland from "@/components/admin/return-to-island";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Exiled - TomodachiShare",
|
||||
description: "You have been exiled from the TomodachiShare island...",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function ExiledPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) redirect("/");
|
||||
|
||||
const activePunishment = await prisma.punishment.findFirst({
|
||||
where: {
|
||||
userId: Number(session?.user.id),
|
||||
returned: false,
|
||||
},
|
||||
include: {
|
||||
violatingMiis: {
|
||||
include: {
|
||||
mii: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!activePunishment) redirect("/");
|
||||
|
||||
const expiresAt = dayjs(activePunishment.expiresAt);
|
||||
const createdAt = dayjs(activePunishment.createdAt);
|
||||
|
||||
const hasExpired = activePunishment.type === "TEMP_EXILE" && activePunishment.expiresAt! > new Date();
|
||||
const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true));
|
||||
|
||||
return (
|
||||
<div className="grow flex items-center justify-center">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xl w-full flex flex-col">
|
||||
<h2 className="text-4xl font-black mb-2">
|
||||
{activePunishment.type === "PERM_EXILE"
|
||||
? "Exiled permanently"
|
||||
: activePunishment.type === "TEMP_EXILE"
|
||||
? `Exiled for ${duration} ${duration === 1 ? "day" : "days"}`
|
||||
: "Warning"}
|
||||
</h2>
|
||||
<p>
|
||||
You have been exiled from the TomodachiShare island because you violated the{" "}
|
||||
<Link href={"/terms-of-service"} className="text-blue-500">
|
||||
Terms of Service
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p className="mt-3">
|
||||
<span className="font-bold">Reviewed:</span> {activePunishment.createdAt.toLocaleDateString("en-GB")} at{" "}
|
||||
{activePunishment.createdAt.toLocaleString("en-GB")}
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<span className="font-bold">Note:</span> {activePunishment.notes}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Violating Items</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{activePunishment.reasons.map((index, reason) => (
|
||||
<div key={index} className="bg-orange-100 rounded-xl border-2 border-orange-400 p-4">
|
||||
<p>
|
||||
<span className="font-bold">Reason:</span> {reason}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{activePunishment.violatingMiis.map((mii) => (
|
||||
<div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
|
||||
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
|
||||
<div className="p-4">
|
||||
<p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold">Reason:</span> {mii.reason}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<hr className="border-zinc-300 mt-2 mb-4" />
|
||||
|
||||
{activePunishment.type !== "PERM_EXILE" ? (
|
||||
<>
|
||||
<p className="mb-2">Once your punishment ends, you can return by checking the box below.</p>
|
||||
{/* <ReturnToIsland hasExpired={hasExpired} /> */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Your punishment is permanent, therefore you cannot return.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
backend/src/app/out/page.tsx
Normal file
72
backend/src/app/out/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Leaving TomodachiShare",
|
||||
description: "Warning: You are leaving TomodachiShare, proceed with caution",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function LinkOutPage({ searchParams }: Props) {
|
||||
const url = (await searchParams).url;
|
||||
if (!url || Array.isArray(url)) redirect("/");
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
redirect("/"); // redirect if URL is invalid
|
||||
}
|
||||
|
||||
// Next.js doesn't allow attacks like these but you can never be too safe
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) redirect("/");
|
||||
|
||||
const isSafe = Array.from(SAFE_LINKS).some((domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`));
|
||||
if (isSafe) redirect(url);
|
||||
|
||||
return (
|
||||
<div className="grow flex items-center justify-center">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg py-8 px-6 max-w-md w-full text-center flex flex-col items-center">
|
||||
<h2 className="text-3xl font-black flex items-center gap-2 mb-1">
|
||||
<Icon icon="mingcute:alert-fill" className="text-5xl" />
|
||||
Warning
|
||||
</h2>
|
||||
<p>You're attempting to leave TomodachiShare island! The destination website is potentially dangerous.</p>
|
||||
|
||||
<div className="bg-zinc-100 border border-zinc-300 rounded-md p-2 break-all w-full mt-4">
|
||||
<code className="font-mono text-sm">{url}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-2">
|
||||
<Link href="/" className="pill button gap-2 mt-8 w-fit self-center bg-zinc-100! border-zinc-300! hover:bg-zinc-300!">
|
||||
<Icon icon="ic:round-home" fontSize={24} />
|
||||
Travel Back
|
||||
</Link>
|
||||
<Link href={url} target="_blank" rel="noopener noreferrer" className="pill button gap-2 mt-8 w-fit self-center">
|
||||
<Icon icon="ic:round-open-in-new" fontSize={21} />
|
||||
Continue
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SAFE_LINKS = new Set([
|
||||
"tomodachishare.com",
|
||||
"trafficlunar.net",
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"twitter.com",
|
||||
"x.com",
|
||||
"reddit.com",
|
||||
"tiktok.com",
|
||||
"tumblr.com",
|
||||
"instagram.com",
|
||||
"wikipedia.org",
|
||||
]);
|
||||
9
backend/src/app/page.tsx
Normal file
9
backend/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export default function IndexPage() {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<p>TomodachiShare API</p>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
27
backend/src/app/profile/[id]/picture/route.ts
Normal file
27
backend/src/app/profile/[id]/picture/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
backend/src/app/random/page.tsx
Normal file
20
backend/src/app/random/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RandomPage() {
|
||||
const count = await prisma.mii.count();
|
||||
if (count === 0) redirect("/");
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * count);
|
||||
const randomMii = await prisma.mii.findFirst({
|
||||
where: { in_queue: false, quarantined: false },
|
||||
skip: randomIndex,
|
||||
take: 1,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!randomMii) redirect(process.env.NEXT_PUBLIC_FRONTEND_URL || "http://localhost:4321");
|
||||
redirect(`${process.env.NEXT_PUBLIC_FRONTEND_URL}/mii/${randomMii.id}`);
|
||||
}
|
||||
47
backend/src/app/report/mii/[id]/page.tsx
Normal file
47
backend/src/app/report/mii/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import ReportMiiForm from "@/components/report/mii-form";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Report Mii - TomodachiShare",
|
||||
description: "Report a Mii on TomodachiShare",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function ReportMiiPage({ params }: Props) {
|
||||
const session = await auth();
|
||||
const { id } = await params;
|
||||
|
||||
const mii = await prisma.mii.findUnique({
|
||||
where: {
|
||||
id: Number(id),
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
likedBy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) redirect("/login");
|
||||
if (!mii) redirect("/404");
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full">
|
||||
<ReportMiiForm mii={mii} likes={mii._count.likedBy} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
backend/src/app/report/user/[id]/page.tsx
Normal file
40
backend/src/app/report/user/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import ReportUserForm from "@/components/report/user-form";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Report User - TomodachiShare",
|
||||
description: "Report a user on TomodachiShare",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function ReportUserPage({ params }: Props) {
|
||||
const session = await auth();
|
||||
const { id } = await params;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: Number(id),
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) redirect("/login");
|
||||
if (!user) redirect("/404");
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full">
|
||||
<ReportUserForm user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
backend/src/app/robots.ts
Normal file
12
backend/src/app/robots.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/*?*page=", "/profile*?*tags=", "/edit/*", "/profile/settings", "/random", "/submit", "/report/mii/*", "/report/user/*", "/admin"],
|
||||
},
|
||||
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
80
backend/src/app/sitemap.ts
Normal file
80
backend/src/app/sitemap.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
type SitemapRoute = MetadataRoute.Sitemap[0];
|
||||
|
||||
export const revalidate = 43200; // update every 12 hours
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
||||
if (!baseUrl) {
|
||||
console.error("NEXT_PUBLIC_BASE_URL environment variable missing");
|
||||
return [];
|
||||
}
|
||||
|
||||
const miis = await prisma.mii.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const dynamicRoutes: MetadataRoute.Sitemap = [
|
||||
...miis.map(
|
||||
(mii) =>
|
||||
({
|
||||
url: `${baseUrl}/mii/${mii.id}`,
|
||||
lastModified: mii.createdAt,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.7,
|
||||
images: [`${baseUrl}/mii/${mii.id}/image?type=metadata`],
|
||||
}) as SitemapRoute,
|
||||
),
|
||||
...users.map(
|
||||
(user) =>
|
||||
({
|
||||
url: `${baseUrl}/profile/${user.id}`,
|
||||
lastModified: user.updatedAt,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.2,
|
||||
}) as SitemapRoute,
|
||||
),
|
||||
];
|
||||
|
||||
const lastModified = new Date();
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified,
|
||||
changeFrequency: "always",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/login`,
|
||||
lastModified,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/privacy`,
|
||||
lastModified,
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.4,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/terms-of-service`,
|
||||
lastModified,
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.4,
|
||||
},
|
||||
...dynamicRoutes,
|
||||
];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue