Merge branch 'main' into feat/living-the-dream-qr-code

This commit is contained in:
trafficlunar 2026-02-24 16:35:54 +00:00
commit 0b1516e930
62 changed files with 2973 additions and 1841 deletions

11
.editorconfig Normal file
View file

@ -0,0 +1,11 @@
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 2
tab_width = 2
max_line_length = 160
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf

View file

@ -6,6 +6,10 @@ REDIS_URL="redis://localhost:6379/0"
# Used for metadata, sitemaps, etc.
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# Used for error tracking
NEXT_PUBLIC_SENTRY_DSN=""
SENTRY_URL=""
# Check Auth.js docs for information
AUTH_URL=http://localhost:3000 # This should be the same as NEXT_PUBLIC_BASE_URL
AUTH_TRUST_HOST=true

2
.gitignore vendored
View file

@ -44,3 +44,5 @@ next-env.d.ts
# tomodachi-share
uploads/
# Sentry Config File
.env.sentry-build-plugin

View file

@ -1,5 +0,0 @@
{
"tabWidth": 2,
"useTabs": true,
"printWidth": 160
}

View file

@ -9,8 +9,6 @@ const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
export default eslintConfig;

View file

@ -1,3 +1,4 @@
import { withSentryConfig } from "@sentry/nextjs";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
@ -31,4 +32,41 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
export default withSentryConfig(nextConfig, {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: "trafficlunar",
project: "tomodachishare",
sentryUrl: process.env.SENTRY_URL,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
// tunnelRoute: "/monitoring",
webpack: {
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: false,
// Tree-shaking options for reducing bundle size
treeshake: {
// Automatically tree-shake Sentry logger statements to reduce bundle size
removeDebugLogging: true,
},
},
});

View file

@ -8,8 +8,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate",
"test": "vitest"
"postinstall": "prisma generate"
},
"dependencies": {
"@2toad/profanity": "^3.2.0",
@ -17,10 +16,11 @@
"@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.2",
"@sentry/nextjs": "^10.39.0",
"bit-buffer": "^0.3.0",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.19",
"downshift": "^9.0.13",
"downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0",
"file-type": "^21.3.0",
"jsqr": "^1.4.0",
@ -29,31 +29,30 @@
"qrcode-generator": "^2.0.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-dropzone": "^14.3.8",
"redis": "^5.10.0",
"satori": "^0.19.1",
"react-dropzone": "^15.0.0",
"redis": "^5.11.0",
"satori": "^0.19.2",
"seedrandom": "^3.0.5",
"sharp": "^0.34.5",
"sjcl-with-all": "1.0.8",
"swr": "^2.3.8",
"swr": "^2.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/postcss": "^4.2.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34",
"eslint": "^9.39.2",
"eslint": "^10.0.1",
"eslint-config-next": "16.1.6",
"prisma": "^6.19.2",
"schema-dts": "^1.1.5",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3"
}
}

File diff suppressed because it is too large Load diff

16
sentry.server.config.ts Normal file
View file

@ -0,0 +1,16 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 0.1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Enable sending user PII (Personally Identifiable Information)
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: false,
});

View file

@ -21,7 +21,7 @@ const punishSchema = z.object({
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(),
});

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { profanity } from "@2toad/profanity";
import z from "zod";
@ -9,6 +10,7 @@ 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 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
@ -27,6 +29,7 @@ export async function PATCH(request: NextRequest) {
});
} catch (error) {
console.error("Failed to update description:", error);
Sentry.captureException(error, { extra: { stage: "update-about-me" } });
return rateLimit.sendResponse({ error: "Failed to update description" }, 500);
}

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
@ -7,6 +8,7 @@ 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 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 1);
const check = await rateLimit.handle();
@ -18,6 +20,7 @@ export async function DELETE(request: NextRequest) {
});
} catch (error) {
console.error("Failed to delete user:", error);
Sentry.captureException(error, { extra: { stage: "delete-account" } });
return rateLimit.sendResponse({ error: "Failed to delete account" }, 500);
}

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth";
@ -9,6 +10,7 @@ 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 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
@ -30,6 +32,7 @@ export async function PATCH(request: NextRequest) {
});
} catch (error) {
console.error("Failed to update display name:", error);
Sentry.captureException(error, { extra: { stage: "update-display-name" } });
return rateLimit.sendResponse({ error: "Failed to update display name" }, 500);
}

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import dayjs from "dayjs";
import { z } from "zod";
@ -20,6 +21,7 @@ const formDataSchema = z.object({
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
@ -68,6 +70,7 @@ export async function PATCH(request: NextRequest) {
await fs.writeFile(fileLocation, webpBuffer);
} catch (error) {
console.error("Error uploading profile picture:", error);
Sentry.captureException(error, { extra: { stage: "upload-profile-picture" } });
return rateLimit.sendResponse({ error: "Failed to store profile picture" }, 500);
}
@ -78,6 +81,7 @@ export async function PATCH(request: NextRequest) {
});
} catch (error) {
console.error("Failed to update profile picture:", error);
Sentry.captureException(error, { extra: { stage: "update-profile-picture" } });
return rateLimit.sendResponse({ error: "Failed to update profile picture" }, 500);
}

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import dayjs from "dayjs";
import { profanity } from "@2toad/profanity";
@ -11,6 +12,7 @@ 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 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
@ -44,6 +46,7 @@ export async function PATCH(request: NextRequest) {
});
} catch (error) {
console.error("Failed to update username:", error);
Sentry.captureException(error, { extra: { stage: "update-username" } });
return rateLimit.sendResponse({ error: "Failed to update username" }, 500);
}

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import fs from "fs/promises";
import path from "path";
@ -13,6 +14,7 @@ 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 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 30, "/api/mii/delete");
const check = await rateLimit.handle();
@ -42,6 +44,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
});
} catch (error) {
console.error("Failed to delete Mii from database:", error);
Sentry.captureException(error, { extra: { stage: "delete-mii" } });
return rateLimit.sendResponse({ error: "Failed to delete Mii" }, 500);
}
@ -49,6 +52,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
await fs.rm(miiUploadsDirectory, { recursive: true, force: true });
} catch (error) {
console.warn("Failed to delete Mii image files:", error);
Sentry.captureException(error, { extra: { stage: "delete-mii-images" } });
}
return rateLimit.sendResponse({ success: true });

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
import { Mii } from "@prisma/client";
@ -28,6 +29,7 @@ const editSchema = z.object({
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 1); // no grouped pathname; edit each mii 1 time a minute
const check = await rateLimit.handle();
@ -128,10 +130,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
await fs.writeFile(fileLocation, webpBuffer);
})
}),
);
} catch (error) {
console.error("Error uploading user images:", error);
Sentry.captureException(error, { extra: { stage: "edit-custom-images" } });
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
}
} else if (description === undefined) {

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
import { Prisma, ReportReason, ReportType } from "@prisma/client";
@ -18,6 +19,7 @@ const reportSchema = z.object({
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 2);
const check = await rateLimit.handle();
@ -83,6 +85,7 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
console.error("Report creation failed", error);
Sentry.captureException(error, { extra: { stage: "create-report" } });
return rateLimit.sendResponse({ error: "Failed to create report" }, 500);
}

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
import fs from "fs/promises";
@ -60,11 +61,13 @@ const submitSchema = z
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Sentry.setUser({ id: session.user.id, username: session.user.username });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const response = await fetch(`${process.env.NEXT_PUBLIC_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 temporarily disabled" }, 503);
@ -77,7 +80,10 @@ export async function POST(request: NextRequest) {
try {
rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
} catch {
} catch (error) {
Sentry.captureException(error, {
extra: { stage: "submit-json-parse" },
});
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
}
@ -145,7 +151,8 @@ export async function POST(request: NextRequest) {
try {
conversion = convertQrCode(qrBytes);
} catch (error) {
return rateLimit.sendResponse({ error }, 400);
Sentry.captureException(error, { extra: { stage: "qr-conversion" } });
return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
}
}
@ -202,6 +209,7 @@ export async function POST(request: NextRequest) {
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Failed to download/store Mii portrait:", error);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "studio-image-download" } });
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
}
@ -227,21 +235,8 @@ export async function POST(request: NextRequest) {
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error processing Mii files:", error);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "file-processing" } });
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
console.error("Error generating QR code:", error);
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
}
try {
await generateMetadataImage(miiRecord, session.user.name!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse(
{
error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}`,
},
500,
);
}
// Compress and store user images
@ -267,6 +262,8 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
console.error("Error storing user images:", error);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "user-image-storage" } });
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
}

View file

@ -44,8 +44,7 @@ export default async function MiiPage({ params }: Props) {
});
// Check ownership
if (!mii || (Number(session?.user.id) !== mii.userId && Number(session?.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)))
redirect("/404");
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} />;
}

23
src/app/global-error.tsx Normal file
View file

@ -0,0 +1,23 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang="en">
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}

View file

@ -44,17 +44,14 @@ body {
.button:disabled {
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
}
.input {
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
}
.input:disabled {
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
}
.checkbox {
@ -94,7 +91,24 @@ body {
@apply opacity-100 scale-100;
}
/* Scrollbars */
/* Fallback Tooltips */
[data-tooltip-span] {
@apply relative;
}
[data-tooltip-span] > .tooltip {
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999;
}
[data-tooltip-span] > .tooltip::before {
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
}
[data-tooltip-span]:hover > .tooltip {
@apply opacity-100 scale-100;
}
/* Scrollbar */
/* Firefox */
* {
scrollbar-color: #ff8903 transparent;

View file

@ -15,6 +15,7 @@ import ShareMiiButton from "@/components/share-mii-button";
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
import Description from "@/components/description";
import { MiiPlatform } from "@prisma/client";
interface Props {
params: Promise<{ id: string }>;
@ -48,13 +49,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
creator: mii.user.username,
openGraph: {
type: "article",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [
{
url: metadataImageUrl,
@ -67,7 +68,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
twitter: {
card: "summary_large_image",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [
{
url: metadataImageUrl,
@ -125,8 +126,8 @@ export default async function MiiPage({ params }: Props) {
<ImageViewer
src={`/mii/${mii.id}/image?type=mii`}
alt="mii headshot"
width={200}
height={200}
width={250}
height={250}
className="drop-shadow-lg hover:scale-105 transition-transform"
/>
</div>
@ -235,7 +236,6 @@ export default async function MiiPage({ params }: Props) {
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
{/* Like button */}
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
</div>
{/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">

View file

@ -10,7 +10,7 @@ export default function PrivacyPage() {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
<h1 className="text-2xl font-bold">Privacy Policy</h1>
<h2 className="font-light">
<strong className="font-medium">Effective Date:</strong> April 06, 2025
<strong className="font-medium">Effective Date:</strong> 21 February 2026
</h2>
<hr className="border-black/20 mt-1 mb-4" />
@ -32,12 +32,11 @@ export default function PrivacyPage() {
<p className="mb-2">The following types of information are stored when you use this website:</p>
<ul className="list-disc list-inside">
<li>
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your username, e-mail, and profile picture
are collected. Your authentication tokens may also be temporarily stored to maintain your login session.
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your username, e-mail, and profile picture are
collected. Your authentication tokens may also be temporarily stored to maintain your login session.
</li>
<li>
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom
images).
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom images).
</li>
<li>
<strong>Interaction Data:</strong> The Miis you like.
@ -49,9 +48,7 @@ export default function PrivacyPage() {
<h3 className="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3>
<section>
<p className="mb-2">
Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.
</p>
<p className="mb-2">Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.</p>
</section>
</li>
<li>
@ -63,18 +60,35 @@ export default function PrivacyPage() {
<a href="https://umami.is/" className="text-blue-700">
Umami
</a>{" "}
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable
information is collected through this service.
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable information is
collected through this service.
</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Error Reporting</h3>
<section>
<p className="mb-2">
This website uses{" "}
<a href="https://glitchtip.com/" className="text-blue-700">
GlitchTip
</a>{" "}
(a self-hosted Sentry-like instance) to monitor errors and site performance. To protect your privacy:
</p>
<ul className="list-disc list-inside ml-4">
<li>Errors and performance data is collected.</li>
<li>Only your user ID and username are sent, no other personally identifiable information is collected.</li>
<li>You can use ad blockers or browser privacy features to opt out.</li>
</ul>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Data Sharing</h3>
<section>
<p className="mb-2">
We do not sell your personal data to third parties. Your data may be sent anonymously to self-hosted third-party services or trusted
third-party tools (such as analytics) but these services are used solely to keep the site functional.
We do not sell your personal data to third parties. Your data may be sent anonymously to self-hosted third-party services or trusted third-party
tools (such as analytics) but these services are used solely to keep the site functional.
</p>
</section>
</li>
@ -95,9 +109,9 @@ export default function PrivacyPage() {
<section>
<p className="mb-2">
Your data, including your Miis, will be retained for as long as you have an account on the site. You may request that your data be
deleted at any time by going to your profile page, clicking the settings icon, and clicking the &apos;Delete Account&apos; button. Upon
clicking, your data will be promptly removed from our servers.
Your data, including your Miis, will be retained for as long as you have an account on the site. You may request that your data be deleted at any
time by going to your profile page, clicking the settings icon, and clicking the &apos;Delete Account&apos; button. Upon clicking, your data will
be promptly removed from our servers.
</p>
</section>
</li>
@ -106,8 +120,7 @@ export default function PrivacyPage() {
<section>
<p className="mb-2">
This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your
privacy.
This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your privacy.
</p>
</section>
</li>

View file

@ -35,7 +35,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "weekly",
priority: 0.7,
images: [`${baseUrl}/mii/${mii.id}/image?type=metadata`],
} as SitemapRoute)
}) as SitemapRoute,
),
...users.map(
(user) =>
@ -44,7 +44,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: user.updatedAt,
changeFrequency: "weekly",
priority: 0.2,
} as SitemapRoute)
}) as SitemapRoute,
),
];

View file

@ -19,26 +19,26 @@ export const metadata: Metadata = {
};
export default async function SubmitPage() {
const session = await auth();
// const session = await auth();
if (!session) redirect("/login");
if (!session.user.username) redirect("/create-username");
const activePunishment = await prisma.punishment.findFirst({
where: {
userId: Number(session?.user.id),
returned: false,
},
});
if (activePunishment) redirect("/off-the-island");
// if (!session) redirect("/login");
// if (!session.user.username) redirect("/create-username");
// const activePunishment = await prisma.punishment.findFirst({
// where: {
// userId: Number(session?.user.id),
// returned: false,
// },
// });
// if (activePunishment) redirect("/off-the-island");
// Check if submissions are disabled
let value: boolean | null = true;
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
value = await response.json();
} catch (error) {
return <p>An error occurred!</p>;
}
// try {
// const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
// value = await response.json();
// } catch (error) {
// return <p>An error occurred!</p>;
// }
if (!value)
return (

View file

@ -16,8 +16,8 @@ export default function PrivacyPage() {
<hr className="border-black/20 mt-1 mb-4" />
<p>
By registering for, or using this service, you confirm that you understand and agree to the terms below. If you do not agree to these terms,
you should not use the service.
By registering for, or using this service, you confirm that you understand and agree to the terms below. If you do not agree to these terms, you should
not use the service.
</p>
<p className="mt-1">
If you have any questions or concerns, please contact me at:{" "}
@ -54,8 +54,8 @@ export default function PrivacyPage() {
<section>
<p className="mb-2">
We reserve the right to suspend or terminate your access to the site at any time if you violate these Terms of Service or engage in any
activities that disrupt the functionality of the site.
We reserve the right to suspend or terminate your access to the site at any time if you violate these Terms of Service or engage in any activities
that disrupt the functionality of the site.
</p>
<p>
To request deletion of your account and personal data, please refer to the{" "}
@ -81,12 +81,12 @@ export default function PrivacyPage() {
<section>
<p className="mb-2">
This service is provided &quot;as is&quot; and without any warranties. We are not responsible for any user-generated content or the
actions of users on the site. You use the site at your own risk.
This service is provided &quot;as is&quot; and without any warranties. We are not responsible for any user-generated content or the actions of
users on the site. You use the site at your own risk.
</p>
<p>
We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of
data, or unauthorized access.
We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of data, or
unauthorized access.
</p>
</section>
</li>
@ -98,7 +98,7 @@ export default function PrivacyPage() {
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "}
<a href="mailto:hello@trafficlunar.net" className="text-blue-700">
hello@trafficlunar.net
</a>
</a>{" "}
or by reporting the Mii on its page.
</p>
<p className="mb-2">Please include:</p>
@ -108,8 +108,8 @@ export default function PrivacyPage() {
<li>A link to the allegedly infringing material</li>
<li>A statement that you have a good faith belief that the use is not authorized</li>
<li>
A statement that the information in the notice is accurate and, under penalty of perjury, that you are authorized to act on behalf of
the copyright owner
A statement that the information in the notice is accurate and, under penalty of perjury, that you are authorized to act on behalf of the
copyright owner
</li>
<li>Your electronic or physical signature</li>
</ul>
@ -120,12 +120,12 @@ export default function PrivacyPage() {
<section>
<p className="mb-2">
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. &quot;Mii&quot; and all related character designs
are trademarks of Nintendo Co., Ltd.
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. &quot;Mii&quot; and all related character designs are
trademarks of Nintendo Co., Ltd.
</p>
<p>
All Mii-related content is shared by users under the assumption that it does not violate any third-party rights. If you believe your
rights have been infringed, please see the DMCA section above.
All Mii-related content is shared by users under the assumption that it does not violate any third-party rights. If you believe your rights have
been infringed, please see the DMCA section above.
</p>
</section>
</li>
@ -134,8 +134,8 @@ export default function PrivacyPage() {
<section>
<p className="mb-2">
This Terms of Service may be updated from time to time. We encourage you to review the terms periodically to stay informed about the use
of the site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
This Terms of Service may be updated from time to time. We encourage you to review the terms periodically to stay informed about the use of the
site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
</p>
</section>
</li>

View file

@ -54,10 +54,7 @@ export default function AdminBanner() {
<span>{data.message}</span>
</div>
<button
onClick={handleClose}
className="min-sm:absolute right-2 cursor-pointer p-1.5"
>
<button onClick={handleClose} className="min-sm:absolute right-2 cursor-pointer p-1.5">
<Icon icon="humbleicons:times" className="text-2xl min-w-6" />
</button>
</div>

View file

@ -87,7 +87,7 @@ export default function PunishmentDeletionDialog({ punishmentId }: Props) {
</div>
</div>
</div>,
document.body
document.body,
)}
</>
);

View file

@ -79,7 +79,7 @@ export default function RegenerateImagesButton() {
</div>
</div>
</div>,
document.body
document.body,
)}
</>
);

View file

@ -68,10 +68,7 @@ export default async function Reports() {
<div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
<div>
<p>Target ID</p>
<Link
href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`}
className="text-blue-600 text-sm"
>
<Link href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
{report.targetId}
</Link>
</div>

View file

@ -286,9 +286,7 @@ export default function Punishments() {
<div key={index} className="bg-white border border-orange-200 rounded-md p-3 flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="bg-orange-200 text-orange-800 border border-orange-400 px-2 py-1 rounded text-xs font-semibold">
ID: {mii.id}
</span>
<span className="bg-orange-200 text-orange-800 border border-orange-400 px-2 py-1 rounded text-xs font-semibold">ID: {mii.id}</span>
<span className="text-sm text-gray-500">{mii.reason}</span>
</div>
</div>

View file

@ -12,7 +12,7 @@ interface Props {
}
export default function Carousel({ images, className }: Props) {
const [emblaRef, emblaApi] = useEmblaCarousel();
const [emblaRef, emblaApi] = useEmblaCarousel({ duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const [isFocused, setIsFocused] = useState(false);

View file

@ -56,13 +56,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
<span>Delete</span>
</button>
) : (
<button
onClick={() => setIsOpen(true)}
aria-label="Delete Mii"
title="Delete Mii"
data-tooltip="Delete"
className="cursor-pointer aspect-square"
>
<button onClick={() => setIsOpen(true)} aria-label="Delete Mii" title="Delete Mii" data-tooltip="Delete" className="cursor-pointer aspect-square">
<Icon icon="mdi:trash" />
</button>
)}
@ -111,7 +105,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
</div>
</div>
</div>,
document.body
document.body,
)}
</>
);

View file

@ -63,12 +63,7 @@ export default function Description({ text, className }: Props) {
href={`/profile/${id}`}
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs"
>
<ProfilePicture
src={linkedProfile.image || "/guest.webp"}
width={24}
height={24}
className="bg-white rounded-lg border-r border-orange-400"
/>
<ProfilePicture src={linkedProfile.image || "/guest.webp"} width={24} height={24} className="bg-white rounded-lg border-r border-orange-400" />
{linkedProfile.name}
</Link>
);

View file

@ -54,11 +54,7 @@ export default function Footer() {
</span>
<a
href="https://trafficlunar.net"
target="_blank"
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group"
>
<a href="https://trafficlunar.net" target="_blank" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group">
Made by <span className="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span>
</a>
</div>

View file

@ -118,7 +118,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
<>
{/* Carousel counter */}
<div
className={`flex justify-center gap-2 bg-orange-300/25 text-orange-300 w-15 font-semibold text-sm py-1 rounded-full border border-orange-300 absolute top-4 left-4 transition-opacity duration-300 ${
className={`flex justify-center gap-2 bg-orange-300 w-15 font-semibold text-sm py-1 rounded-full border-2 border-orange-400 absolute top-4 left-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
@ -147,7 +147,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
{/* Carousel snaps */}
<div
className={`flex justify-center gap-2 bg-orange-300/25 p-2.5 rounded-full border border-orange-300 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
className={`flex justify-center gap-2 bg-orange-300 p-2.5 rounded-full border-2 border-orange-400 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
@ -156,7 +156,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
key={index}
aria-label={`Go to ${index} in Carousel`}
onClick={() => emblaApi?.scrollTo(index)}
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-orange-300 w-8" : "bg-orange-300/40"}`}
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
/>
))}
</div>

View file

@ -21,7 +21,7 @@ export default function Pagination({ lastPage }: Props) {
params.set("page", pageNumber.toString());
return `${pathname}?${params.toString()}`;
},
[searchParams, pathname]
[searchParams, pathname],
);
const numbers = useMemo(() => {
@ -44,9 +44,7 @@ export default function Pagination({ lastPage }: Props) {
aria-label="Go to First Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
}`}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-double-left" />
</Link>
@ -83,9 +81,7 @@ export default function Pagination({ lastPage }: Props) {
aria-label="Go to Next Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
}`}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-right" />
</Link>
@ -96,9 +92,7 @@ export default function Pagination({ lastPage }: Props) {
aria-label="Go to Last Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
}`}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-double-right" />
</Link>

View file

@ -51,8 +51,7 @@ export default async function ProfileInformation({ userId, page }: Props) {
<div className="mt-3 text-sm flex gap-8">
<h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>
<span className="font-medium">Created:</span>{" "}
{user.createdAt.toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })}
<span className="font-medium">Created:</span> {user.createdAt.toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })}
</h4>
<h4>
Liked <span className="font-bold">{likedMiis}</span> Miis

View file

@ -7,12 +7,7 @@ export default async function ProfileOverview() {
return (
<li title="Your profile">
<Link
href={`/profile/${session?.user.id}`}
aria-label="Go to profile"
className="pill button gap-2! p-0! h-full max-w-64"
data-tooltip="Your Profile"
>
<Link href={`/profile/${session?.user.id}`} aria-label="Go to profile" className="pill button gap-2! p-0! h-full max-w-64" data-tooltip="Your Profile">
<Image
src={session?.user?.image ?? "/guest.webp"}
alt="profile picture"

View file

@ -39,11 +39,7 @@ export default function DeleteAccount() {
return (
<>
<button
name="deletion"
onClick={() => setIsOpen(true)}
className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!"
>
<button name="deletion" onClick={() => setIsOpen(true)} className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!">
Delete Account
</button>
@ -69,9 +65,7 @@ export default function DeleteAccount() {
</button>
</div>
<p className="text-sm text-zinc-500">
Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.
</p>
<p className="text-sm text-zinc-500">Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.</p>
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
@ -83,7 +77,7 @@ export default function DeleteAccount() {
</div>
</div>
</div>,
document.body
document.body,
)}
</>
);

View file

@ -151,13 +151,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
</div>
<div className="flex justify-end gap-1 h-min col-span-2">
<input
type="text"
className="pill input flex-1"
placeholder="Type here..."
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<input type="text" className="pill input flex-1" placeholder="Type here..." value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
<SubmitDialogButton
title="Confirm Display Name Change"
description="Are you sure? This will only be visible on your profile. You can change it again later."

View file

@ -86,8 +86,8 @@ export default function ProfilePictureSettings() {
onSubmit={handleSubmit}
>
<p className="text-sm text-zinc-500 mt-2">
After submitting, you can change it again on{" "}
{changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}.
After submitting, you can change it again on {changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
.
</p>
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center">

View file

@ -76,7 +76,7 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
</div>
</div>
</div>,
document.body
document.body,
)}
</>
);

View file

@ -36,12 +36,7 @@ export default function ReasonSelector({ reason, setReason }: Props) {
return (
<div className="relative w-full col-span-2">
{/* Toggle button to open the dropdown */}
<button
type="button"
{...getToggleButtonProps()}
aria-label="Report reason dropdown"
className="pill input w-full gap-1 justify-between! text-nowrap"
>
<button type="button" {...getToggleButtonProps()} aria-label="Report reason dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
{selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>

View file

@ -91,11 +91,7 @@ export default function ShareMiiButton({ miiId }: Props) {
<input type="text" disabled className="pill input w-full text-sm" value={url} />
{/* Copy button */}
<button
className="absolute! top-2.5 right-2.5 cursor-pointer"
data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"}
onClick={handleCopyUrl}
>
<button className="absolute! top-2.5 right-2.5 cursor-pointer" data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"} onClick={handleCopyUrl}>
<div className="relative text-xl">
{/* Copy icon */}
<Icon
@ -124,14 +120,7 @@ export default function ShareMiiButton({ miiId }: Props) {
</div>
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
<Image
src={`/mii/${miiId}/image?type=metadata`}
alt="mii 'metadata' image"
width={248}
height={248}
unoptimized
className="drop-shadow-md"
/>
<Image src={`/mii/${miiId}/image?type=metadata`} alt="mii 'metadata' image" width={248} height={248} unoptimized className="drop-shadow-md" />
</div>
<div className="flex justify-end gap-2 mt-4">
@ -158,9 +147,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Copy icon */}
<Icon
icon="solar:copy-bold"
className={` transition-all duration-300 ${
hasCopiedImage ? "opacity-0 scale-75 rotate-12" : "opacity-100 scale-100 rotate-0"
}`}
className={` transition-all duration-300 ${hasCopiedImage ? "opacity-0 scale-75 rotate-12" : "opacity-100 scale-100 rotate-0"}`}
/>
{/* Check icon */}
@ -180,7 +167,7 @@ export default function ShareMiiButton({ miiId }: Props) {
</div>
</div>
</div>,
document.body
document.body,
)}
</>
);

View file

@ -30,7 +30,7 @@ export default function EditForm({ mii, likes }: Props) {
setFiles((prev) => [...prev, ...acceptedFiles]);
},
[files.length]
[files.length],
);
const [error, setError] = useState<string | undefined>(undefined);
@ -91,7 +91,7 @@ export default function EditForm({ mii, likes }: Props) {
const blob = await response.blob();
return Object.assign(new File([blob], `image${index}.webp`, { type: "image/webp" }), { path });
})
}),
);
setFiles(existing);
@ -107,9 +107,7 @@ export default function EditForm({ mii, likes }: Props) {
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center">
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel
images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]}
/>
<Carousel images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]} />
<div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}>

View file

@ -144,10 +144,8 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
};
}, [isOpen, permissionGranted, selectedDeviceId, scanQRCode]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
@ -165,8 +163,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
</button>
</div>
{devices.length > 1 && (
<div className="mb-4 flex flex-col gap-1">
<div className={`mb-4 flex flex-col gap-1 ${devices.length <= 1 ? "hidden" : ""}`}>
<label className="text-sm font-semibold">Camera:</label>
<div className="relative w-full">
{/* Toggle button to open the dropdown */}
@ -201,7 +198,6 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
</ul>
</div>
</div>
)}
<div className="relative w-full aspect-square">
{!permissionGranted && (

View file

@ -43,7 +43,7 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
};
reader.readAsDataURL(file);
},
[setQrBytesRaw]
[setQrBytesRaw],
);
return (

View file

@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import confetti from "canvas-confetti";
import ReturnToIsland from "../admin/return-to-island";
interface Slide {
// step is never used, undefined is assumed as a step
@ -30,7 +29,7 @@ interface Props {
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0);
// Build index map
@ -102,9 +101,7 @@ export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
/>
<div
@ -191,11 +188,7 @@ export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
</button>
{/* Only show tutorial name on step slides */}
<span
className={`text-sm transition-opacity duration-300 ${
(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"
}`}
>
<span className={`text-sm transition-opacity duration-300 ${(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"}`}>
{currentSlide?.tutorialTitle}
</span>

View file

@ -0,0 +1,42 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import Tutorial from ".";
export default function ScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
<Icon icon="fa:question-circle" />
<span>Tutorial</span>
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
{ text: "2. Go into 'QR Code'", imageSrc: "/tutorial/adding-mii/step2.png" },
{ text: "3. Press 'Scan QR Code'", imageSrc: "/tutorial/adding-mii/step3.png" },
{ text: "4. Click on the QR code below the Mii's image", imageSrc: "/tutorial/adding-mii/step4.png" },
{ text: "5. Scan with your 3DS", imageSrc: "/tutorial/adding-mii/step5.png" },
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import Tutorial from ".";
export default function SubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to?
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Allow Copying",
thumbnail: "/tutorial/allow-copying/thumbnail.png",
hint: "Suggested!",
steps: [
{ type: "start" },
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
{ text: "2. Go into 'Mii List'", imageSrc: "/tutorial/allow-copying/step2.png" },
{ text: "3. Select and edit the Mii you wish to submit", imageSrc: "/tutorial/allow-copying/step3.png" },
{ text: "4. Click 'Other Settings' in the information screen", imageSrc: "/tutorial/allow-copying/step4.png" },
{ text: "5. Click on 'Don't Allow' under the 'Copying' text", imageSrc: "/tutorial/allow-copying/step5.png" },
{ text: "6. Press 'Allow'", imageSrc: "/tutorial/allow-copying/step6.png" },
{ text: "7. Confirm the edits to the Mii", imageSrc: "/tutorial/allow-copying/step7.png" },
{ type: "finish" },
],
},
{
title: "Create QR Code",
thumbnail: "/tutorial/create-qr-code/thumbnail.png",
steps: [
{ type: "start" },
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
{ text: "2. Go into 'QR Code'", imageSrc: "/tutorial/create-qr-code/step2.png" },
{ text: "3. Press 'Create QR Code'", imageSrc: "/tutorial/create-qr-code/step3.png" },
{ text: "4. Select and press 'OK' on the Mii you wish to submit", imageSrc: "/tutorial/create-qr-code/step4.png" },
{
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
imageSrc: "/tutorial/create-qr-code/step5.png",
},
{
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
imageSrc: "/tutorial/create-qr-code/step6.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -0,0 +1,18 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 0.1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Enable sending user PII (Personally Identifiable Information)
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: false,
});
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

9
src/instrumentation.ts Normal file
View file

@ -0,0 +1,9 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("../sentry.server.config");
}
}
export const onRequestError = Sentry.captureRequestError;

View file

@ -4,6 +4,7 @@
/* eslint-disable @next/next/no-img-element */
import type { ReactNode } from "react";
import * as Sentry from "@sentry/nextjs";
import fs from "fs/promises";
import path from "path";
@ -62,6 +63,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
if (!moderationResponse.ok) {
console.error("Moderation API error");
Sentry.captureException("Moderation API error", { extra: { stage: "moderation-api-response", status: moderationResponse.status } });
return { valid: false, error: "Content moderation check failed", status: 500 };
}
@ -71,13 +73,15 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
}
} catch (moderationError) {
console.error("Error fetching moderation API:", moderationError);
Sentry.captureException(moderationError, { extra: { stage: "moderation-api-fetch" } });
return { valid: false, error: "Moderation API is down", status: 503 };
}
return { valid: true };
} catch (error) {
console.error("Error validating image:", error);
return { valid: false, error: "Failed to process image file.", status: 500 };
Sentry.captureException(error, { extra: { stage: "image-validation" } });
return { valid: false, error: "Failed to process image file", status: 500 };
}
}
//#endregion
@ -117,7 +121,7 @@ const loadFonts = async (): Promise<Font[]> => {
};
}
return fontCache[weight]!;
})
}),
);
};
@ -131,13 +135,13 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
sharp(buffer)
.png()
.toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`)
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
),
fs.readFile(path.join(miiUploadsDirectory, "qr-code.webp")).then((buffer) =>
sharp(buffer)
.png()
.toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`)
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
),
loadFonts(),
]);
@ -225,6 +229,7 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
await fs.writeFile(fileLocation, buffer);
} catch (error) {
console.error("Error storing 'metadata' image type", error);
Sentry.captureException(error, { extra: { stage: "metadata-image-storage", miiId: mii.id } });
return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
}

View file

@ -66,14 +66,7 @@ const STUDIO_RENDER_CLOTHES_COLORS = [
"black",
];
const STUDIO_RENDER_LIGHT_DIRECTION_MODS = [
"none",
"zerox",
"flipx",
"camera",
"offset",
"set",
];
const STUDIO_RENDER_LIGHT_DIRECTION_MODS = ["none", "zerox", "flipx", "camera", "offset", "set"];
const STUDIO_RENDER_INSTANCE_ROTATION_MODES = ["model", "camera", "both"];
@ -165,285 +158,79 @@ export default class Mii {
public validate(): void {
// Size check
assert.equal(
this.bitStream.length / 8,
0x60,
`Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`,
);
assert.equal(this.bitStream.length / 8, 0x60, `Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`);
// Value range and type checks
assert.ok(
this.version === 0 || this.version === 3,
`Invalid Mii version. Got ${this.version}, expected 0 or 3`,
);
assert.equal(
typeof this.allowCopying,
"boolean",
`Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`,
);
assert.equal(
typeof this.profanityFlag,
"boolean",
`Invalid Mii profanity flag. Got ${this.profanityFlag}, expected true or false`,
);
assert.ok(
Util.inRange(this.regionLock, Util.range(4)),
`Invalid Mii region lock. Got ${this.regionLock}, expected 0-3`,
);
assert.ok(
Util.inRange(this.characterSet, Util.range(4)),
`Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`,
);
assert.ok(
Util.inRange(this.pageIndex, Util.range(10)),
`Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`,
);
assert.ok(
Util.inRange(this.slotIndex, Util.range(10)),
`Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`,
);
assert.equal(
this.unknown1,
0,
`Invalid Mii unknown1. Got ${this.unknown1}, expected 0`,
);
assert.ok(
Util.inRange(this.deviceOrigin, Util.range(1, 5)),
`Invalid Mii device origin. Got ${this.deviceOrigin}, expected 1-4`,
);
assert.equal(
this.systemId.length,
8,
`Invalid Mii system ID size. Got ${this.systemId.length}, system IDs must be 8 bytes long`,
);
assert.equal(
typeof this.normalMii,
"boolean",
`Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`,
);
assert.equal(
typeof this.dsMii,
"boolean",
`Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`,
);
assert.equal(
typeof this.nonUserMii,
"boolean",
`Invalid non-user Mii flag. Got ${this.nonUserMii}, expected true or false`,
);
assert.equal(
typeof this.isValid,
"boolean",
`Invalid Mii valid flag. Got ${this.isValid}, expected true or false`,
);
assert.ok(
this.creationTime < 268435456,
`Invalid Mii creation time. Got ${this.creationTime}, max value for 28 bit integer is 268,435,456`,
);
assert.equal(
this.consoleMAC.length,
6,
`Invalid Mii console MAC address size. Got ${this.consoleMAC.length}, console MAC addresses must be 6 bytes long`,
);
assert.ok(
Util.inRange(this.gender, Util.range(2)),
`Invalid Mii gender. Got ${this.gender}, expected 0 or 1`,
);
assert.ok(
Util.inRange(this.birthMonth, Util.range(13)),
`Invalid Mii birth month. Got ${this.birthMonth}, expected 0-12`,
);
assert.ok(
Util.inRange(this.birthDay, Util.range(32)),
`Invalid Mii birth day. Got ${this.birthDay}, expected 0-31`,
);
assert.ok(
Util.inRange(this.favoriteColor, Util.range(12)),
`Invalid Mii favorite color. Got ${this.favoriteColor}, expected 0-11`,
);
assert.equal(
typeof this.favorite,
"boolean",
`Invalid favorite Mii flag. Got ${this.favorite}, expected true or false`,
);
assert.ok(
Buffer.from(this.miiName, "utf16le").length <= 0x14,
`Invalid Mii name. Got ${this.miiName}, name may only be up to 10 characters`,
);
assert.ok(
Util.inRange(this.height, Util.range(128)),
`Invalid Mii height. Got ${this.height}, expected 0-127`,
);
assert.ok(
Util.inRange(this.build, Util.range(128)),
`Invalid Mii build. Got ${this.build}, expected 0-127`,
);
assert.equal(
typeof this.disableSharing,
"boolean",
`Invalid disable sharing Mii flag. Got ${this.disableSharing}, expected true or false`,
);
assert.ok(
Util.inRange(this.faceType, Util.range(12)),
`Invalid Mii face type. Got ${this.faceType}, expected 0-11`,
);
assert.ok(
Util.inRange(this.skinColor, Util.range(7)),
`Invalid Mii skin color. Got ${this.skinColor}, expected 0-6`,
);
assert.ok(
Util.inRange(this.wrinklesType, Util.range(12)),
`Invalid Mii wrinkles type. Got ${this.wrinklesType}, expected 0-11`,
);
assert.ok(
Util.inRange(this.makeupType, Util.range(12)),
`Invalid Mii makeup type. Got ${this.makeupType}, expected 0-11`,
);
assert.ok(
Util.inRange(this.hairType, Util.range(132)),
`Invalid Mii hair type. Got ${this.hairType}, expected 0-131`,
);
assert.ok(this.version === 0 || this.version === 3, `Invalid Mii version. Got ${this.version}, expected 0 or 3`);
assert.equal(typeof this.allowCopying, "boolean", `Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`);
assert.equal(typeof this.profanityFlag, "boolean", `Invalid Mii profanity flag. Got ${this.profanityFlag}, expected true or false`);
assert.ok(Util.inRange(this.regionLock, Util.range(4)), `Invalid Mii region lock. Got ${this.regionLock}, expected 0-3`);
assert.ok(Util.inRange(this.characterSet, Util.range(4)), `Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`);
assert.ok(Util.inRange(this.pageIndex, Util.range(10)), `Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`);
assert.ok(Util.inRange(this.slotIndex, Util.range(10)), `Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`);
assert.equal(this.unknown1, 0, `Invalid Mii unknown1. Got ${this.unknown1}, expected 0`);
assert.ok(Util.inRange(this.deviceOrigin, Util.range(1, 5)), `Invalid Mii device origin. Got ${this.deviceOrigin}, expected 1-4`);
assert.equal(this.systemId.length, 8, `Invalid Mii system ID size. Got ${this.systemId.length}, system IDs must be 8 bytes long`);
assert.equal(typeof this.normalMii, "boolean", `Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`);
assert.equal(typeof this.dsMii, "boolean", `Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`);
assert.equal(typeof this.nonUserMii, "boolean", `Invalid non-user Mii flag. Got ${this.nonUserMii}, expected true or false`);
assert.equal(typeof this.isValid, "boolean", `Invalid Mii valid flag. Got ${this.isValid}, expected true or false`);
assert.ok(this.creationTime < 268435456, `Invalid Mii creation time. Got ${this.creationTime}, max value for 28 bit integer is 268,435,456`);
assert.equal(this.consoleMAC.length, 6, `Invalid Mii console MAC address size. Got ${this.consoleMAC.length}, console MAC addresses must be 6 bytes long`);
assert.ok(Util.inRange(this.gender, Util.range(2)), `Invalid Mii gender. Got ${this.gender}, expected 0 or 1`);
assert.ok(Util.inRange(this.birthMonth, Util.range(13)), `Invalid Mii birth month. Got ${this.birthMonth}, expected 0-12`);
assert.ok(Util.inRange(this.birthDay, Util.range(32)), `Invalid Mii birth day. Got ${this.birthDay}, expected 0-31`);
assert.ok(Util.inRange(this.favoriteColor, Util.range(12)), `Invalid Mii favorite color. Got ${this.favoriteColor}, expected 0-11`);
assert.equal(typeof this.favorite, "boolean", `Invalid favorite Mii flag. Got ${this.favorite}, expected true or false`);
assert.ok(Buffer.from(this.miiName, "utf16le").length <= 0x14, `Invalid Mii name. Got ${this.miiName}, name may only be up to 10 characters`);
assert.ok(Util.inRange(this.height, Util.range(128)), `Invalid Mii height. Got ${this.height}, expected 0-127`);
assert.ok(Util.inRange(this.build, Util.range(128)), `Invalid Mii build. Got ${this.build}, expected 0-127`);
assert.equal(typeof this.disableSharing, "boolean", `Invalid disable sharing Mii flag. Got ${this.disableSharing}, expected true or false`);
assert.ok(Util.inRange(this.faceType, Util.range(12)), `Invalid Mii face type. Got ${this.faceType}, expected 0-11`);
assert.ok(Util.inRange(this.skinColor, Util.range(7)), `Invalid Mii skin color. Got ${this.skinColor}, expected 0-6`);
assert.ok(Util.inRange(this.wrinklesType, Util.range(12)), `Invalid Mii wrinkles type. Got ${this.wrinklesType}, expected 0-11`);
assert.ok(Util.inRange(this.makeupType, Util.range(12)), `Invalid Mii makeup type. Got ${this.makeupType}, expected 0-11`);
assert.ok(Util.inRange(this.hairType, Util.range(132)), `Invalid Mii hair type. Got ${this.hairType}, expected 0-131`);
// assert.ok(Util.inRange(this.hairColor, Util.range(8)), `Invalid Mii hair color. Got ${this.hairColor}, expected 0-7`);
assert.equal(
typeof this.flipHair,
"boolean",
`Invalid flip hair flag. Got ${this.flipHair}, expected true or false`,
);
assert.ok(
Util.inRange(this.eyeType, Util.range(60)),
`Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`,
);
assert.ok(
Util.inRange(this.eyeColor, Util.range(6)),
`Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`,
);
assert.ok(
Util.inRange(this.eyeScale, Util.range(8)),
`Invalid Mii eye scale. Got ${this.eyeScale}, expected 0-7`,
);
assert.ok(
Util.inRange(this.eyeVerticalStretch, Util.range(7)),
`Invalid Mii eye vertical stretch. Got ${this.eyeVerticalStretch}, expected 0-6`,
);
assert.ok(
Util.inRange(this.eyeRotation, Util.range(8)),
`Invalid Mii eye rotation. Got ${this.eyeRotation}, expected 0-7`,
);
assert.ok(
Util.inRange(this.eyeSpacing, Util.range(13)),
`Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`,
);
assert.ok(
Util.inRange(this.eyeYPosition, Util.range(19)),
`Invalid Mii eye Y position. Got ${this.eyeYPosition}, expected 0-18`,
);
assert.ok(
Util.inRange(this.eyebrowType, Util.range(25)),
`Invalid Mii eyebrow type. Got ${this.eyebrowType}, expected 0-24`,
);
assert.equal(typeof this.flipHair, "boolean", `Invalid flip hair flag. Got ${this.flipHair}, expected true or false`);
assert.ok(Util.inRange(this.eyeType, Util.range(60)), `Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`);
assert.ok(Util.inRange(this.eyeColor, Util.range(6)), `Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`);
assert.ok(Util.inRange(this.eyeScale, Util.range(8)), `Invalid Mii eye scale. Got ${this.eyeScale}, expected 0-7`);
assert.ok(Util.inRange(this.eyeVerticalStretch, Util.range(7)), `Invalid Mii eye vertical stretch. Got ${this.eyeVerticalStretch}, expected 0-6`);
assert.ok(Util.inRange(this.eyeRotation, Util.range(8)), `Invalid Mii eye rotation. Got ${this.eyeRotation}, expected 0-7`);
assert.ok(Util.inRange(this.eyeSpacing, Util.range(13)), `Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`);
assert.ok(Util.inRange(this.eyeYPosition, Util.range(19)), `Invalid Mii eye Y position. Got ${this.eyeYPosition}, expected 0-18`);
assert.ok(Util.inRange(this.eyebrowType, Util.range(25)), `Invalid Mii eyebrow type. Got ${this.eyebrowType}, expected 0-24`);
// assert.ok(Util.inRange(this.eyebrowColor, Util.range(8)), `Invalid Mii eyebrow color. Got ${this.eyebrowColor}, expected 0-7`);
assert.ok(
Util.inRange(this.eyebrowScale, Util.range(9)),
`Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`,
);
assert.ok(Util.inRange(this.eyebrowScale, Util.range(9)), `Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`);
assert.ok(
Util.inRange(this.eyebrowVerticalStretch, Util.range(7)),
`Invalid Mii eyebrow vertical stretch. Got ${this.eyebrowVerticalStretch}, expected 0-6`,
);
assert.ok(
Util.inRange(this.eyebrowRotation, Util.range(12)),
`Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`,
);
assert.ok(
Util.inRange(this.eyebrowSpacing, Util.range(13)),
`Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`,
);
assert.ok(
Util.inRange(this.eyebrowYPosition, Util.range(3, 19)),
`Invalid Mii eyebrow Y position. Got ${this.eyebrowYPosition}, expected 3-18`,
);
assert.ok(
Util.inRange(this.noseType, Util.range(18)),
`Invalid Mii nose type. Got ${this.noseType}, expected 0-17`,
);
assert.ok(
Util.inRange(this.noseScale, Util.range(9)),
`Invalid Mii nose scale. Got ${this.noseScale}, expected 0-8`,
);
assert.ok(
Util.inRange(this.noseYPosition, Util.range(19)),
`Invalid Mii nose Y position. Got ${this.noseYPosition}, expected 0-18`,
);
assert.ok(
Util.inRange(this.mouthType, Util.range(36)),
`Invalid Mii mouth type. Got ${this.mouthType}, expected 0-35`,
);
assert.ok(
Util.inRange(this.mouthColor, Util.range(5)),
`Invalid Mii mouth color. Got ${this.mouthColor}, expected 0-4`,
);
assert.ok(
Util.inRange(this.mouthScale, Util.range(9)),
`Invalid Mii mouth scale. Got ${this.mouthScale}, expected 0-8`,
);
assert.ok(
Util.inRange(this.mouthHorizontalStretch, Util.range(7)),
`Invalid Mii mouth stretch. Got ${this.mouthHorizontalStretch}, expected 0-6`,
);
assert.ok(
Util.inRange(this.mouthYPosition, Util.range(19)),
`Invalid Mii mouth Y position. Got ${this.mouthYPosition}, expected 0-18`,
);
assert.ok(
Util.inRange(this.mustacheType, Util.range(6)),
`Invalid Mii mustache type. Got ${this.mustacheType}, expected 0-5`,
);
assert.ok(
Util.inRange(this.beardType, Util.range(6)),
`Invalid Mii beard type. Got ${this.beardType}, expected 0-5`,
);
assert.ok(Util.inRange(this.eyebrowRotation, Util.range(12)), `Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`);
assert.ok(Util.inRange(this.eyebrowSpacing, Util.range(13)), `Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`);
assert.ok(Util.inRange(this.eyebrowYPosition, Util.range(3, 19)), `Invalid Mii eyebrow Y position. Got ${this.eyebrowYPosition}, expected 3-18`);
assert.ok(Util.inRange(this.noseType, Util.range(18)), `Invalid Mii nose type. Got ${this.noseType}, expected 0-17`);
assert.ok(Util.inRange(this.noseScale, Util.range(9)), `Invalid Mii nose scale. Got ${this.noseScale}, expected 0-8`);
assert.ok(Util.inRange(this.noseYPosition, Util.range(19)), `Invalid Mii nose Y position. Got ${this.noseYPosition}, expected 0-18`);
assert.ok(Util.inRange(this.mouthType, Util.range(36)), `Invalid Mii mouth type. Got ${this.mouthType}, expected 0-35`);
assert.ok(Util.inRange(this.mouthColor, Util.range(5)), `Invalid Mii mouth color. Got ${this.mouthColor}, expected 0-4`);
assert.ok(Util.inRange(this.mouthScale, Util.range(9)), `Invalid Mii mouth scale. Got ${this.mouthScale}, expected 0-8`);
assert.ok(Util.inRange(this.mouthHorizontalStretch, Util.range(7)), `Invalid Mii mouth stretch. Got ${this.mouthHorizontalStretch}, expected 0-6`);
assert.ok(Util.inRange(this.mouthYPosition, Util.range(19)), `Invalid Mii mouth Y position. Got ${this.mouthYPosition}, expected 0-18`);
assert.ok(Util.inRange(this.mustacheType, Util.range(6)), `Invalid Mii mustache type. Got ${this.mustacheType}, expected 0-5`);
assert.ok(Util.inRange(this.beardType, Util.range(6)), `Invalid Mii beard type. Got ${this.beardType}, expected 0-5`);
// assert.ok(Util.inRange(this.facialHairColor, Util.range(8)), `Invalid Mii beard type. Got ${this.facialHairColor}, expected 0-7`);
assert.ok(
Util.inRange(this.mustacheScale, Util.range(9)),
`Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`,
);
assert.ok(
Util.inRange(this.mustacheYPosition, Util.range(17)),
`Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`,
);
assert.ok(
Util.inRange(this.glassesType, Util.range(9)),
`Invalid Mii glassess type. Got ${this.glassesType}, expected 0-8`,
);
assert.ok(
Util.inRange(this.glassesColor, Util.range(6)),
`Invalid Mii glassess type. Got ${this.glassesColor}, expected 0-5`,
);
assert.ok(
Util.inRange(this.glassesScale, Util.range(8)),
`Invalid Mii glassess type. Got ${this.glassesScale}, expected 0-7`,
);
assert.ok(
Util.inRange(this.glassesYPosition, Util.range(21)),
`Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`,
);
assert.equal(
typeof this.moleEnabled,
"boolean",
`Invalid mole enabled flag. Got ${this.moleEnabled}, expected true or false`,
);
assert.ok(
Util.inRange(this.moleScale, Util.range(9)),
`Invalid Mii mole scale. Got ${this.moleScale}, expected 0-8`,
);
assert.ok(
Util.inRange(this.moleXPosition, Util.range(17)),
`Invalid Mii mole X position. Got ${this.moleXPosition}, expected 0-16`,
);
assert.ok(
Util.inRange(this.moleYPosition, Util.range(31)),
`Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`,
);
assert.ok(Util.inRange(this.mustacheScale, Util.range(9)), `Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`);
assert.ok(Util.inRange(this.mustacheYPosition, Util.range(17)), `Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`);
assert.ok(Util.inRange(this.glassesType, Util.range(9)), `Invalid Mii glassess type. Got ${this.glassesType}, expected 0-8`);
assert.ok(Util.inRange(this.glassesColor, Util.range(6)), `Invalid Mii glassess type. Got ${this.glassesColor}, expected 0-5`);
assert.ok(Util.inRange(this.glassesScale, Util.range(8)), `Invalid Mii glassess type. Got ${this.glassesScale}, expected 0-7`);
assert.ok(Util.inRange(this.glassesYPosition, Util.range(21)), `Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`);
assert.equal(typeof this.moleEnabled, "boolean", `Invalid mole enabled flag. Got ${this.moleEnabled}, expected true or false`);
assert.ok(Util.inRange(this.moleScale, Util.range(9)), `Invalid Mii mole scale. Got ${this.moleScale}, expected 0-8`);
assert.ok(Util.inRange(this.moleXPosition, Util.range(17)), `Invalid Mii mole X position. Got ${this.moleXPosition}, expected 0-16`);
assert.ok(Util.inRange(this.moleYPosition, Util.range(31)), `Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`);
// Sanity checks
/*
@ -459,10 +246,7 @@ export default class Mii {
}
*/
if (
this.nonUserMii &&
(this.creationTime !== 0 || this.isValid || this.dsMii || this.normalMii)
) {
if (this.nonUserMii && (this.creationTime !== 0 || this.isValid || this.dsMii || this.normalMii)) {
assert.fail("Non-user Mii's must have all other Mii ID bits set to 0");
}
@ -569,11 +353,7 @@ export default class Mii {
public calculateCRC(): number {
// #view is inaccessible
const data = new Uint8Array(
this.buffer.buffer,
this.buffer.byteOffset,
this.buffer.length,
).subarray(0, 0x5e);
const data = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset, this.buffer.length).subarray(0, 0x5e);
let crc = 0x0000;
@ -727,23 +507,11 @@ export default class Mii {
data: this.encodeStudio().toString("hex"),
};
params.type = STUDIO_RENDER_TYPES.includes(params.type as string)
? params.type
: STUDIO_RENDER_DEFAULTS.type;
params.expression = STUDIO_RENDER_EXPRESSIONS.includes(
params.expression as string,
)
? params.expression
: STUDIO_RENDER_DEFAULTS.expression;
params.type = STUDIO_RENDER_TYPES.includes(params.type as string) ? params.type : STUDIO_RENDER_DEFAULTS.type;
params.expression = STUDIO_RENDER_EXPRESSIONS.includes(params.expression as string) ? params.expression : STUDIO_RENDER_DEFAULTS.expression;
params.width = Util.clamp(params.width, 512);
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string)
? params.bgColor
: STUDIO_RENDER_DEFAULTS.bgColor;
params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(
params.clothesColor,
)
? params.clothesColor
: STUDIO_RENDER_DEFAULTS.clothesColor;
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string) ? params.bgColor : STUDIO_RENDER_DEFAULTS.bgColor;
params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(params.clothesColor) ? params.clothesColor : STUDIO_RENDER_DEFAULTS.clothesColor;
params.cameraXRotate = Util.clamp(params.cameraXRotate, 359);
params.cameraYRotate = Util.clamp(params.cameraYRotate, 359);
params.cameraZRotate = Util.clamp(params.cameraZRotate, 359);
@ -753,25 +521,16 @@ export default class Mii {
params.lightXDirection = Util.clamp(params.lightXDirection, 359);
params.lightYDirection = Util.clamp(params.lightYDirection, 359);
params.lightZDirection = Util.clamp(params.lightZDirection, 359);
params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes(
params.lightDirectionMode,
)
params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes(params.lightDirectionMode)
? params.lightDirectionMode
: STUDIO_RENDER_DEFAULTS.lightDirectionMode;
params.instanceCount = Util.clamp(params.instanceCount, 1, 16);
params.instanceRotationMode =
STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(
params.instanceRotationMode,
)
params.instanceRotationMode = STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(params.instanceRotationMode)
? params.instanceRotationMode
: STUDIO_RENDER_DEFAULTS.instanceRotationMode;
// converts non-string params to strings
const query = new URLSearchParams(
Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value.toString()]),
),
);
const query = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([key, value]) => [key, value.toString()])));
if (params.lightDirectionMode === "none") {
query.delete("lightDirectionMode");

View file

@ -1,126 +0,0 @@
import { convertQrCode } from "./qr-codes";
import { describe, it, expect } from "vitest";
import Mii from "./mii.js/mii";
import { TomodachiLifeMii, HairDyeMode } from "./tomodachi-life-mii";
// List of encrypted QR code data to test against.
const validQrCodes = [
// Frieren (from step6.webp)
{
base64: "lymEx6TA4eLgfwTEceW+DbdYBTfBfPKM4VesJkq1qzoGGXnk/3OohvPPuGS8mmnzvpvyC36ge8+C2c4IIhJ0l7Lx9KKxnPEAuLBM1YtCnSowjVNHo3CmjE7D7lkDUBuhO3qKjAqWXfQthtqBqjTe4Hv95TKNVPimaxNXhAVZGSmOMh++0Z2N0TvIDpU/8kxLIsgntsQH4PNlaFcVF2HeOERXRdTm/TMFb6pozO57nJ9NKi5bxh1ClNArbpyTQgBe7cfvnNretFVNWzGJBWctZC7weecYIKyU3qbH5c4kogmj4WcfJhYsuOT3odvv2WBaGhchgR779Ztc0E76COxNEaJa6M7QDyHGw8XQfxCH3j4pMkhFOlPn/ObS3rNUADYUCY8d+Wt4fBbO7PTW7ppDnDaCyxwEIbglsMtkD/cIPIr4f0RPnpV7ZlOmrJ3HdIbmwXi6TqKTwqkHtBmBPvZVpXm4RJN8A6gF22Uc7NY8B3KMYY7Q",
expected: {
miiName: "Frieren",
firstName: "Frieren",
lastName: "the Slayer",
islandName: "Wuhu",
},
},
{
base64: "ky1cf9hr9xotl250Y8cDOGqPd7t51NS8tNVrJMRAI2bfXr4LNirKvqu7ZrvC00vgz70NU8kQRR6H3uAnRaHupxjLbeYfU0s6CduruAEnXP8rZanCeSePQQH0NSL3QqiilT2WEt7nCAMvHwR9fT/LE/k6NBMDHqoK3zqzemr4OlQro0YWBeRJ501EawWan/k/rq2VSGTeLO09CsQD6AFHECtxF9+sSKyJxK1aiu7VhmOZLY6L9VKrlpvahQ1/vHVyYVpFJvc3bdHE8D94bBXkZ18mnXj++ST+j1Had4aki7oqTT83fgs7asRg3DRRZArw8PiKVmZWJ4ODRWR/LNvjIxb1FQqWk9I6S3DEo6AMuBRbXgj1H4YWrRuTkWlEpP2Y/P5+Mvfv5GbNQKYSKwpTYFOCTn13yQ1wtDbF4yG+Ro4Xf45cNBT6k3yqswrKt9bkP2wiULqYZR7McaD1SJ4whFFqcadjpLvbn8zQNFY0lOUTQMGI",
expected: {
miiName: "ミク",
firstName: "ミク",
lastName: "はつね",
islandName: "LOSTの",
},
},
];
// 372 bytes of zeroes.
const zeroes372 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
// 32 bytes of zeroes (too short to be a Mii QR code).
const length32 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
// Mii QR code encrypted correctly but has an invalid CRC-16 checksum.
const badCrc = "ULENrXILeXZooDaJvwHMcFaBjGH7k5pj+DDPDV0irq66c01Fh82TIrZerZX2xiB+TbHBzYIaDHdryA9IbR1uH/slSalGCScmjDiL9zpOXlvh8z38NGkmjtL1fVVrshCcJHKeQApdbZU4S6h+wZd0LA==";
// Hair dye should be applied to hair only.
const hairDyeHairOnly = "lHDnfRgqe2+drU303Slkj7+o4JldrKcIdOl5zgLM0LpwQKQY+i3cpt5IIg7LBNAr7TCzHOvi698oUV0QkcyNj71MgtAAaw4MvOdT4dsv0PLof6E7IcjgnCA1ZAJ2Bs5PQTnM/yuVBUIXdq6WYh+nmG3HtxV7zKbEpSy4bqVep8uuvUlfZcB+BQgucPXQLmDnS8ECKwOlANcKTI+ZjIZggVaEsyY88pjRWyXnwe1z4Favw16bIzecesehGlqXzZh9U5Vm5dZP8wmKc3G6TGylYmbnloRd99UYRNULvTQCUer8WljGuV30ftXlJOwfsnwoAiVOGoG3KvbsBpPtPLywR5DavRgQIPd0/b+XUzHQDhkyftMXeqVEalsuEmU/b/1/j4yVL+2lWgD1i2xyET65uJawAnd8jbKbG8lxPMgzIKGVqJB4QmJOl9/dTf21r9GgRFRaFEz+66bVfiYhzXKmJUQv2qx/t/V3r96QzYd08nrWSHK0";
const hairDyeHairOnlyExpectedCommonColor = 99; // Must apply to hair but not eyebrow and beard.
// Hair dye should be applied to hair, eyebrow, and beard.
const hairDyeHairEyebrowBeard = validQrCodes[1].base64; // Miku has hair dye.
const hairDyeHairEyebrowBeardExpectedCommonColor = 67; // Must apply to hair, eyebrow, and beard.
// Should not have hair dye enabled.
const hairDyeMode0 = validQrCodes[0].base64; // Frieren doesn't have hair dye
function base64ToUint8Array(base64: string): Uint8Array {
const binary = Buffer.from(base64, "base64");
return new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength);
}
describe("convertQrCode", () => {
it.each(validQrCodes.map((t, i) => [i, t]))(
"should convert valid QR #%i into Mii + TomodachiLifeMii",
(_, { base64, expected }) => {
const bytes = base64ToUint8Array(base64);
const { mii, tomodachiLifeMii } = convertQrCode(bytes);
expect(mii).toBeInstanceOf(Mii);
expect(tomodachiLifeMii).toBeInstanceOf(TomodachiLifeMii);
expect(mii.miiName).toBe(expected.miiName);
expect(tomodachiLifeMii.firstName).toBe(expected.firstName);
expect(tomodachiLifeMii.lastName).toBe(expected.lastName);
expect(tomodachiLifeMii.islandName).toBe(expected.islandName);
});
it("should throw an error on zeroed out data", () => {
const bytes = base64ToUint8Array(zeroes372);
// Will decrypt wrongly, leading to the expected stream
// of zeroes in the decrypted data not being intact.
expect(() => convertQrCode(bytes)).toThrow("QR code is not a valid Mii QR code");
});
it("should throw an error on wrong length", () => {
const bytes = base64ToUint8Array(length32);
// Thrown at the beginning of the function.
expect(() => convertQrCode(bytes)).toThrow("Mii QR code has wrong size (got 32, expected 112 or longer)");
});
it("should throw an error on data with bad CRC-16", () => {
const bytes = base64ToUint8Array(badCrc);
// Verified by new Mii() constructor from mii-js.
expect(() => convertQrCode(bytes)).toThrow("Mii data is not valid");
});
it("should apply hair dye to hair, eyebrow, and beard", () => {
const bytes = base64ToUint8Array(hairDyeHairEyebrowBeard);
const { mii, tomodachiLifeMii } = convertQrCode(bytes);
expect(tomodachiLifeMii.hairDyeMode).toBe(HairDyeMode.HairEyebrowBeard);
expect(tomodachiLifeMii.studioHairColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor);
expect(mii.hairColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor);
expect(mii.eyebrowColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor);
expect(mii.facialHairColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor);
});
it("should apply hair dye to hair only", () => {
const bytes = base64ToUint8Array(hairDyeHairOnly);
const { mii, tomodachiLifeMii } = convertQrCode(bytes);
expect(tomodachiLifeMii.hairDyeMode).toBe(HairDyeMode.Hair);
expect(tomodachiLifeMii.studioHairColor).toBe(hairDyeHairOnlyExpectedCommonColor);
expect(mii.hairColor).toBe(hairDyeHairOnlyExpectedCommonColor);
expect(mii.eyebrowColor === hairDyeHairOnlyExpectedCommonColor).toBe(false);
expect(mii.facialHairColor === hairDyeHairOnlyExpectedCommonColor).toBe(false);
});
it("should not apply hair dye if mode is 0", () => {
const bytes = base64ToUint8Array(hairDyeMode0);
const { mii, tomodachiLifeMii } = convertQrCode(bytes);
expect(tomodachiLifeMii.hairDyeMode).toBe(HairDyeMode.None);
expect(mii.hairColor === tomodachiLifeMii.studioHairColor).toBe(false);
expect(mii.hairColor === mii.facialHairColor).toBe(false);
});
/*
it('should censor bad words in names', () => {
const qrWithSwears = // TODO TODO
const { tomodachiLifeMii } = convertQrCode(qrWithSwears);
expect(tomodachiLifeMii.firstName).not.toMatch(/INSERT_SWEARS_HERE/i);
});
*/
});

View file

@ -17,14 +17,13 @@ import { TomodachiLifeMii, HairDyeMode } from "./tomodachi-life-mii";
// In "sjcl-with-all" v1.0.8 from npm, the name is "u"
/** Private _ctrMode function defined here: {@link https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L194} */
const sjclCcmCtrMode: ((
prf: sjcl.SjclCipher, data: sjcl.BitArray, iv: sjcl.BitArray,
tag: sjcl.BitArray, tlen: number, L: number
) => { data: sjcl.BitArray; tag: sjcl.BitArray }) | undefined =
const sjclCcmCtrMode:
| ((prf: sjcl.SjclCipher, data: sjcl.BitArray, iv: sjcl.BitArray, tag: sjcl.BitArray, tlen: number, L: number) => { data: sjcl.BitArray; tag: sjcl.BitArray })
| undefined =
// @ts-expect-error -- Referencing a private function that is not in the types.
sjcl.mode.ccm.u; // NOTE: This may need to be changed with a different sjcl build. Read above
export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } {
export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | never {
// Decrypt 96 byte 3DS/Wii U format Mii data from the QR code.
// References (Credits: jaames, kazuki-4ys):
// - https://gist.github.com/jaames/96ce8daa11b61b758b6b0227b55f9f78
@ -32,7 +31,9 @@ export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii:
// Check that the private _ctrMode function is defined.
if (!sjclCcmCtrMode) {
throw new Error("Private sjcl.mode.ccm._ctrMode function cannot be found. The build of sjcl expected may have changed. Read src/lib/qr-codes.ts for more details.");
throw new Error(
"Private sjcl.mode.ccm._ctrMode function cannot be found. The build of sjcl expected may have changed. Read src/lib/qr-codes.ts for more details.",
);
}
// Verify that the length is not smaller than expected.
@ -52,9 +53,11 @@ export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii:
// Isolate the actual ciphertext from the tag and adjust IV.
// Copied from sjcl.mode.ccm.decrypt: https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L83
const tlen = 128; // Tag length in bits.
const dataWithoutTag = sjcl.bitArray.clamp(encryptedBits,
const dataWithoutTag = sjcl.bitArray.clamp(
encryptedBits,
// remove tag from out, tag length = 128
sjcl.bitArray.bitLength(encryptedBits) - tlen);
sjcl.bitArray.bitLength(encryptedBits) - tlen,
);
let decryptedBits: { data: sjcl.BitArray };
try {

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient, RedisClientType } from "redis";
import * as Sentry from "@sentry/nextjs";
import { auth } from "./auth";
const WINDOW_SIZE = 60;
@ -17,7 +18,10 @@ async function getRedisClient() {
client = createClient({
url: process.env.REDIS_URL,
});
client.on("error", (err) => console.error("Redis client error", err));
client.on("error", (error) => {
console.error("Redis client error", error);
Sentry.captureException(error, { tags: { source: "redis-client" } });
});
await client.connect();
}
return client;
@ -67,6 +71,7 @@ export class RateLimit {
return { success, limit: this.maxRequests, remaining, expires: expireAt };
} catch (error) {
console.error("Rate limit check failed", error);
Sentry.captureException(error, { tags: { source: "rate-limit-check" } });
return {
success: false,
limit: this.maxRequests,

View file

@ -7,9 +7,7 @@ sjcl.beware["CTR mode is dangerous because it doesn't protect message integrity.
// Converts hair dye to studio color
// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/9906440c1dafbe3f40ac8b95e10a22ebd85b441e/assets/data-conversion.js#L282
// (Credits to kat21)
const hairDyeConverter = [
55, 51, 50, 12, 16, 12, 67, 61, 51, 64, 69, 66, 65, 86, 85, 93, 92, 19, 20, 20, 15, 32, 35, 26, 38, 41, 43, 18, 95, 97, 97, 99,
];
const hairDyeConverter = [55, 51, 50, 12, 16, 12, 67, 61, 51, 64, 69, 66, 65, 86, 85, 93, 92, 19, 20, 20, 15, 32, 35, 26, 38, 41, 43, 18, 95, 97, 97, 99];
// All possible values for 2-bit hair dye mode.
export enum HairDyeMode {