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. # Used for metadata, sitemaps, etc.
NEXT_PUBLIC_BASE_URL=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000
# Used for error tracking
NEXT_PUBLIC_SENTRY_DSN=""
SENTRY_URL=""
# Check Auth.js docs for information # Check Auth.js docs for information
AUTH_URL=http://localhost:3000 # This should be the same as NEXT_PUBLIC_BASE_URL AUTH_URL=http://localhost:3000 # This should be the same as NEXT_PUBLIC_BASE_URL
AUTH_TRUST_HOST=true AUTH_TRUST_HOST=true

2
.gitignore vendored
View file

@ -44,3 +44,5 @@ next-env.d.ts
# tomodachi-share # tomodachi-share
uploads/ 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, baseDirectory: __dirname,
}); });
const eslintConfig = [ const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig; export default eslintConfig;

View file

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

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({ 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" }), 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(), reason: z.string(),
}) }),
) )
.optional(), .optional(),
}); });

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { profanity } from "@2toad/profanity"; import { profanity } from "@2toad/profanity";
import z from "zod"; import z from "zod";
@ -9,6 +10,7 @@ import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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 rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle(); const check = await rateLimit.handle();
@ -27,6 +29,7 @@ export async function PATCH(request: NextRequest) {
}); });
} catch (error) { } catch (error) {
console.error("Failed to update description:", 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); return rateLimit.sendResponse({ error: "Failed to update description" }, 500);
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { z } from "zod"; import { z } from "zod";
import fs from "fs/promises"; import fs from "fs/promises";
@ -60,11 +61,13 @@ const submitSchema = z
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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 rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle(); const check = await rateLimit.handle();
if (check) return check; 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 response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
const { value } = await response.json(); const { value } = await response.json();
if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503); if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503);
@ -77,7 +80,10 @@ export async function POST(request: NextRequest) {
try { try {
rawTags = JSON.parse(formData.get("tags") as string); rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") 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); return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
} }
@ -145,7 +151,8 @@ export async function POST(request: NextRequest) {
try { try {
conversion = convertQrCode(qrBytes); conversion = convertQrCode(qrBytes);
} catch (error) { } 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 } }); await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Failed to download/store Mii portrait:", error); 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); 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 } }); await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error processing Mii files:", error); 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); 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 // Compress and store user images
@ -267,6 +262,8 @@ export async function POST(request: NextRequest) {
}); });
} catch (error) { } catch (error) {
console.error("Error storing user images:", 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); 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 // Check ownership
if (!mii || (Number(session?.user.id) !== mii.userId && Number(session?.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) if (!mii || (Number(session?.user.id) !== mii.userId && Number(session?.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) redirect("/404");
redirect("/404");
return <EditForm mii={mii} likes={mii._count.likedBy} />; 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 { .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;
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
} }
.input { .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;
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
} }
.input:disabled { .input:disabled {
@apply text-zinc-600 bg-zinc-100! border-zinc-300!; @apply text-zinc-600 bg-zinc-100! border-zinc-300!;
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
} }
.checkbox { .checkbox {
@ -94,7 +91,24 @@ body {
@apply opacity-100 scale-100; @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 */ /* Firefox */
* { * {
scrollbar-color: #ff8903 transparent; 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 ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan"; import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
import Description from "@/components/description"; import Description from "@/components/description";
import { MiiPlatform } from "@prisma/client";
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -48,13 +49,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return { return {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${mii.name} - TomodachiShare`, 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], keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
creator: mii.user.username, creator: mii.user.username,
openGraph: { openGraph: {
type: "article", type: "article",
title: `${mii.name} - TomodachiShare`, 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: [ images: [
{ {
url: metadataImageUrl, url: metadataImageUrl,
@ -67,7 +68,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: `${mii.name} - TomodachiShare`, 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: [ images: [
{ {
url: metadataImageUrl, url: metadataImageUrl,
@ -125,8 +126,8 @@ export default async function MiiPage({ params }: Props) {
<ImageViewer <ImageViewer
src={`/mii/${mii.id}/image?type=mii`} src={`/mii/${mii.id}/image?type=mii`}
alt="mii headshot" alt="mii headshot"
width={200} width={250}
height={200} height={250}
className="drop-shadow-lg hover:scale-105 transition-transform" className="drop-shadow-lg hover:scale-105 transition-transform"
/> />
</div> </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> <h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
{/* Like button */} {/* 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 />
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
</div> </div>
{/* Tags */} {/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs"> <div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">

View file

@ -10,7 +10,7 @@ export default function PrivacyPage() {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
<h1 className="text-2xl font-bold">Privacy Policy</h1> <h1 className="text-2xl font-bold">Privacy Policy</h1>
<h2 className="font-light"> <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> </h2>
<hr className="border-black/20 mt-1 mb-4" /> <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> <p className="mb-2">The following types of information are stored when you use this website:</p>
<ul className="list-disc list-inside"> <ul className="list-disc list-inside">
<li> <li>
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your username, e-mail, and profile picture <strong>Account Information:</strong> When you sign up or log in using Discord or Github, your username, e-mail, and profile picture are
are collected. Your authentication tokens may also be temporarily stored to maintain your login session. collected. Your authentication tokens may also be temporarily stored to maintain your login session.
</li> </li>
<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 <strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom images).
images).
</li> </li>
<li> <li>
<strong>Interaction Data:</strong> The Miis you like. <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> <h3 className="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3>
<section> <section>
<p className="mb-2"> <p className="mb-2">Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.</p>
Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.
</p>
</section> </section>
</li> </li>
<li> <li>
@ -63,18 +60,35 @@ export default function PrivacyPage() {
<a href="https://umami.is/" className="text-blue-700"> <a href="https://umami.is/" className="text-blue-700">
Umami Umami
</a>{" "} </a>{" "}
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable information is
information is collected through this service. collected through this service.
</p> </p>
</section> </section>
</li> </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> <li>
<h3 className="text-xl font-semibold mt-6 mb-2">Data Sharing</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Data Sharing</h3>
<section> <section>
<p className="mb-2"> <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 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
third-party tools (such as analytics) but these services are used solely to keep the site functional. tools (such as analytics) but these services are used solely to keep the site functional.
</p> </p>
</section> </section>
</li> </li>
@ -95,9 +109,9 @@ export default function PrivacyPage() {
<section> <section>
<p className="mb-2"> <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 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
deleted at any time by going to your profile page, clicking the settings icon, and clicking the &apos;Delete Account&apos; button. Upon time by going to your profile page, clicking the settings icon, and clicking the &apos;Delete Account&apos; button. Upon clicking, your data will
clicking, your data will be promptly removed from our servers. be promptly removed from our servers.
</p> </p>
</section> </section>
</li> </li>
@ -106,8 +120,7 @@ export default function PrivacyPage() {
<section> <section>
<p className="mb-2"> <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 This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your privacy.
privacy.
</p> </p>
</section> </section>
</li> </li>

View file

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

View file

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

View file

@ -16,8 +16,8 @@ export default function PrivacyPage() {
<hr className="border-black/20 mt-1 mb-4" /> <hr className="border-black/20 mt-1 mb-4" />
<p> <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, 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
you should not use the service. not use the service.
</p> </p>
<p className="mt-1"> <p className="mt-1">
If you have any questions or concerns, please contact me at:{" "} If you have any questions or concerns, please contact me at:{" "}
@ -54,8 +54,8 @@ export default function PrivacyPage() {
<section> <section>
<p className="mb-2"> <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 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
activities that disrupt the functionality of the site. that disrupt the functionality of the site.
</p> </p>
<p> <p>
To request deletion of your account and personal data, please refer to the{" "} To request deletion of your account and personal data, please refer to the{" "}
@ -81,12 +81,12 @@ export default function PrivacyPage() {
<section> <section>
<p className="mb-2"> <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 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
actions of users on the site. You use the site at your own risk. users on the site. You use the site at your own risk.
</p> </p>
<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 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
data, or unauthorized access. unauthorized access.
</p> </p>
</section> </section>
</li> </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{" "} 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"> <a href="mailto:hello@trafficlunar.net" className="text-blue-700">
hello@trafficlunar.net hello@trafficlunar.net
</a> </a>{" "}
or by reporting the Mii on its page. or by reporting the Mii on its page.
</p> </p>
<p className="mb-2">Please include:</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 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 you have a good faith belief that the use is not authorized</li>
<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 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
the copyright owner copyright owner
</li> </li>
<li>Your electronic or physical signature</li> <li>Your electronic or physical signature</li>
</ul> </ul>
@ -120,12 +120,12 @@ export default function PrivacyPage() {
<section> <section>
<p className="mb-2"> <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 This site is not affiliated with, endorsed by, or associated with Nintendo in any way. &quot;Mii&quot; and all related character designs are
are trademarks of Nintendo Co., Ltd. trademarks of Nintendo Co., Ltd.
</p> </p>
<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 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
rights have been infringed, please see the DMCA section above. been infringed, please see the DMCA section above.
</p> </p>
</section> </section>
</li> </li>
@ -134,8 +134,8 @@ export default function PrivacyPage() {
<section> <section>
<p className="mb-2"> <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 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
of the site. We may notify users via a site banner or other means if changes are made to the Terms of Service. site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
</p> </p>
</section> </section>
</li> </li>

View file

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

View file

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

View file

@ -79,7 +79,7 @@ export default function RegenerateImagesButton() {
</div> </div>
</div> </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 className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
<div> <div>
<p>Target ID</p> <p>Target ID</p>
<Link <Link href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`}
className="text-blue-600 text-sm"
>
{report.targetId} {report.targetId}
</Link> </Link>
</div> </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 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-1">
<div className="flex items-center gap-2"> <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"> <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>
ID: {mii.id}
</span>
<span className="text-sm text-gray-500">{mii.reason}</span> <span className="text-sm text-gray-500">{mii.reason}</span>
</div> </div>
</div> </div>

View file

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

View file

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

View file

@ -63,12 +63,7 @@ export default function Description({ text, className }: Props) {
href={`/profile/${id}`} 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" 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 <ProfilePicture src={linkedProfile.image || "/guest.webp"} width={24} height={24} className="bg-white rounded-lg border-r border-orange-400" />
src={linkedProfile.image || "/guest.webp"}
width={24}
height={24}
className="bg-white rounded-lg border-r border-orange-400"
/>
{linkedProfile.name} {linkedProfile.name}
</Link> </Link>
); );

View file

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

View file

@ -118,7 +118,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
<> <>
{/* Carousel counter */} {/* Carousel counter */}
<div <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" isVisible ? "opacity-100" : "opacity-0"
}`} }`}
> >
@ -147,7 +147,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
{/* Carousel snaps */} {/* Carousel snaps */}
<div <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" isVisible ? "opacity-100" : "opacity-0"
}`} }`}
> >
@ -156,7 +156,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
key={index} key={index}
aria-label={`Go to ${index} in Carousel`} aria-label={`Go to ${index} in Carousel`}
onClick={() => emblaApi?.scrollTo(index)} 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> </div>

View file

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

View file

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

View file

@ -7,12 +7,7 @@ export default async function ProfileOverview() {
return ( return (
<li title="Your profile"> <li title="Your profile">
<Link <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">
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 <Image
src={session?.user?.image ?? "/guest.webp"} src={session?.user?.image ?? "/guest.webp"}
alt="profile picture" alt="profile picture"

View file

@ -39,11 +39,7 @@ export default function DeleteAccount() {
return ( return (
<> <>
<button <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!">
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 Delete Account
</button> </button>
@ -69,9 +65,7 @@ export default function DeleteAccount() {
</button> </button>
</div> </div>
<p className="text-sm text-zinc-500"> <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>
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>} {error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
@ -83,7 +77,7 @@ export default function DeleteAccount() {
</div> </div>
</div> </div>
</div>, </div>,
document.body document.body,
)} )}
</> </>
); );

View file

@ -151,13 +151,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
</div> </div>
<div className="flex justify-end gap-1 h-min col-span-2"> <div className="flex justify-end gap-1 h-min col-span-2">
<input <input type="text" className="pill input flex-1" placeholder="Type here..." value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
type="text"
className="pill input flex-1"
placeholder="Type here..."
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<SubmitDialogButton <SubmitDialogButton
title="Confirm Display Name Change" title="Confirm Display Name Change"
description="Are you sure? This will only be visible on your profile. You can change it again later." 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} onSubmit={handleSubmit}
> >
<p className="text-sm text-zinc-500 mt-2"> <p className="text-sm text-zinc-500 mt-2">
After submitting, you can change it again on{" "} After submitting, you can change it again on {changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
{changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}. .
</p> </p>
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center"> <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> </div>
</div>, </div>,
document.body document.body,
)} )}
</> </>
); );

View file

@ -36,12 +36,7 @@ export default function ReasonSelector({ reason, setReason }: Props) {
return ( return (
<div className="relative w-full col-span-2"> <div className="relative w-full col-span-2">
{/* Toggle button to open the dropdown */} {/* Toggle button to open the dropdown */}
<button <button type="button" {...getToggleButtonProps()} aria-label="Report reason dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
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>} {selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" /> <Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button> </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} /> <input type="text" disabled className="pill input w-full text-sm" value={url} />
{/* Copy button */} {/* Copy button */}
<button <button className="absolute! top-2.5 right-2.5 cursor-pointer" data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"} onClick={handleCopyUrl}>
className="absolute! top-2.5 right-2.5 cursor-pointer"
data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"}
onClick={handleCopyUrl}
>
<div className="relative text-xl"> <div className="relative text-xl">
{/* Copy icon */} {/* Copy icon */}
<Icon <Icon
@ -124,14 +120,7 @@ export default function ShareMiiButton({ miiId }: Props) {
</div> </div>
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg"> <div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
<Image <Image src={`/mii/${miiId}/image?type=metadata`} alt="mii 'metadata' image" width={248} height={248} unoptimized className="drop-shadow-md" />
src={`/mii/${miiId}/image?type=metadata`}
alt="mii 'metadata' image"
width={248}
height={248}
unoptimized
className="drop-shadow-md"
/>
</div> </div>
<div className="flex justify-end gap-2 mt-4"> <div className="flex justify-end gap-2 mt-4">
@ -158,9 +147,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Copy icon */} {/* Copy icon */}
<Icon <Icon
icon="solar:copy-bold" icon="solar:copy-bold"
className={` transition-all duration-300 ${ className={` transition-all duration-300 ${hasCopiedImage ? "opacity-0 scale-75 rotate-12" : "opacity-100 scale-100 rotate-0"}`}
hasCopiedImage ? "opacity-0 scale-75 rotate-12" : "opacity-100 scale-100 rotate-0"
}`}
/> />
{/* Check icon */} {/* Check icon */}
@ -180,7 +167,7 @@ export default function ShareMiiButton({ miiId }: Props) {
</div> </div>
</div> </div>
</div>, </div>,
document.body document.body,
)} )}
</> </>
); );

View file

@ -30,7 +30,7 @@ export default function EditForm({ mii, likes }: Props) {
setFiles((prev) => [...prev, ...acceptedFiles]); setFiles((prev) => [...prev, ...acceptedFiles]);
}, },
[files.length] [files.length],
); );
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
@ -91,7 +91,7 @@ export default function EditForm({ mii, likes }: Props) {
const blob = await response.blob(); const blob = await response.blob();
return Object.assign(new File([blob], `image${index}.webp`, { type: "image/webp" }), { path }); return Object.assign(new File([blob], `image${index}.webp`, { type: "image/webp" }), { path });
}) }),
); );
setFiles(existing); 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"> <form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-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"> <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel <Carousel images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]} />
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"> <div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}> <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]); }, [isOpen, permissionGranted, selectedDeviceId, scanQRCode]);
if (!isOpen) return null;
return ( 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 <div
onClick={close} 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"}`}
@ -165,8 +163,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
</button> </button>
</div> </div>
{devices.length > 1 && ( <div className={`mb-4 flex flex-col gap-1 ${devices.length <= 1 ? "hidden" : ""}`}>
<div className="mb-4 flex flex-col gap-1">
<label className="text-sm font-semibold">Camera:</label> <label className="text-sm font-semibold">Camera:</label>
<div className="relative w-full"> <div className="relative w-full">
{/* Toggle button to open the dropdown */} {/* Toggle button to open the dropdown */}
@ -201,7 +198,6 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
</ul> </ul>
</div> </div>
</div> </div>
)}
<div className="relative w-full aspect-square"> <div className="relative w-full aspect-square">
{!permissionGranted && ( {!permissionGranted && (

View file

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

View file

@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react"; import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import ReturnToIsland from "../admin/return-to-island";
interface Slide { interface Slide {
// step is never used, undefined is assumed as a step // step is never used, undefined is assumed as a step
@ -30,7 +29,7 @@ interface Props {
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) { export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
// Build index map // 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 className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div <div
onClick={close} onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
isVisible ? "opacity-100" : "opacity-0"
}`}
/> />
<div <div
@ -191,11 +188,7 @@ export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
</button> </button>
{/* Only show tutorial name on step slides */} {/* Only show tutorial name on step slides */}
<span <span className={`text-sm transition-opacity duration-300 ${(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"}`}>
className={`text-sm transition-opacity duration-300 ${
(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"
}`}
>
{currentSlide?.tutorialTitle} {currentSlide?.tutorialTitle}
</span> </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 */ /* eslint-disable @next/next/no-img-element */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import * as Sentry from "@sentry/nextjs";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
@ -62,6 +63,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
if (!moderationResponse.ok) { if (!moderationResponse.ok) {
console.error("Moderation API error"); 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 }; 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) { } catch (moderationError) {
console.error("Error fetching moderation API:", 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: false, error: "Moderation API is down", status: 503 };
} }
return { valid: true }; return { valid: true };
} catch (error) { } catch (error) {
console.error("Error validating image:", 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 //#endregion
@ -117,7 +121,7 @@ const loadFonts = async (): Promise<Font[]> => {
}; };
} }
return fontCache[weight]!; return fontCache[weight]!;
}) }),
); );
}; };
@ -131,13 +135,13 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
sharp(buffer) sharp(buffer)
.png() .png()
.toBuffer() .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) => fs.readFile(path.join(miiUploadsDirectory, "qr-code.webp")).then((buffer) =>
sharp(buffer) sharp(buffer)
.png() .png()
.toBuffer() .toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`) .then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
), ),
loadFonts(), loadFonts(),
]); ]);
@ -225,6 +229,7 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
await fs.writeFile(fileLocation, buffer); await fs.writeFile(fileLocation, buffer);
} catch (error) { } catch (error) {
console.error("Error storing 'metadata' image type", 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 }; return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
} }

View file

@ -66,14 +66,7 @@ const STUDIO_RENDER_CLOTHES_COLORS = [
"black", "black",
]; ];
const STUDIO_RENDER_LIGHT_DIRECTION_MODS = [ const STUDIO_RENDER_LIGHT_DIRECTION_MODS = ["none", "zerox", "flipx", "camera", "offset", "set"];
"none",
"zerox",
"flipx",
"camera",
"offset",
"set",
];
const STUDIO_RENDER_INSTANCE_ROTATION_MODES = ["model", "camera", "both"]; const STUDIO_RENDER_INSTANCE_ROTATION_MODES = ["model", "camera", "both"];
@ -165,285 +158,79 @@ export default class Mii {
public validate(): void { public validate(): void {
// Size check // Size check
assert.equal( assert.equal(this.bitStream.length / 8, 0x60, `Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`);
this.bitStream.length / 8,
0x60,
`Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`,
);
// Value range and type checks // Value range and type checks
assert.ok( assert.ok(this.version === 0 || this.version === 3, `Invalid Mii version. Got ${this.version}, expected 0 or 3`);
this.version === 0 || this.version === 3, assert.equal(typeof this.allowCopying, "boolean", `Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`);
`Invalid Mii version. Got ${this.version}, expected 0 or 3`, 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.equal( assert.ok(Util.inRange(this.characterSet, Util.range(4)), `Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`);
typeof this.allowCopying, assert.ok(Util.inRange(this.pageIndex, Util.range(10)), `Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`);
"boolean", assert.ok(Util.inRange(this.slotIndex, Util.range(10)), `Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`);
`Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`, 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( assert.equal(this.systemId.length, 8, `Invalid Mii system ID size. Got ${this.systemId.length}, system IDs must be 8 bytes long`);
typeof this.profanityFlag, assert.equal(typeof this.normalMii, "boolean", `Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`);
"boolean", assert.equal(typeof this.dsMii, "boolean", `Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`);
`Invalid Mii profanity flag. Got ${this.profanityFlag}, 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( assert.ok(this.creationTime < 268435456, `Invalid Mii creation time. Got ${this.creationTime}, max value for 28 bit integer is 268,435,456`);
Util.inRange(this.regionLock, Util.range(4)), assert.equal(this.consoleMAC.length, 6, `Invalid Mii console MAC address size. Got ${this.consoleMAC.length}, console MAC addresses must be 6 bytes long`);
`Invalid Mii region lock. Got ${this.regionLock}, expected 0-3`, 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( assert.ok(Util.inRange(this.birthDay, Util.range(32)), `Invalid Mii birth day. Got ${this.birthDay}, expected 0-31`);
Util.inRange(this.characterSet, Util.range(4)), assert.ok(Util.inRange(this.favoriteColor, Util.range(12)), `Invalid Mii favorite color. Got ${this.favoriteColor}, expected 0-11`);
`Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`, 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( assert.ok(Util.inRange(this.height, Util.range(128)), `Invalid Mii height. Got ${this.height}, expected 0-127`);
Util.inRange(this.pageIndex, Util.range(10)), assert.ok(Util.inRange(this.build, Util.range(128)), `Invalid Mii build. Got ${this.build}, expected 0-127`);
`Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`, 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( assert.ok(Util.inRange(this.skinColor, Util.range(7)), `Invalid Mii skin color. Got ${this.skinColor}, expected 0-6`);
Util.inRange(this.slotIndex, Util.range(10)), assert.ok(Util.inRange(this.wrinklesType, Util.range(12)), `Invalid Mii wrinkles type. Got ${this.wrinklesType}, expected 0-11`);
`Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`, 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.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.ok(Util.inRange(this.hairColor, Util.range(8)), `Invalid Mii hair color. Got ${this.hairColor}, expected 0-7`);
assert.equal( assert.equal(typeof this.flipHair, "boolean", `Invalid flip hair flag. Got ${this.flipHair}, expected true or false`);
typeof this.flipHair, assert.ok(Util.inRange(this.eyeType, Util.range(60)), `Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`);
"boolean", assert.ok(Util.inRange(this.eyeColor, Util.range(6)), `Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`);
`Invalid flip hair flag. Got ${this.flipHair}, expected true or false`, 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( assert.ok(Util.inRange(this.eyeRotation, Util.range(8)), `Invalid Mii eye rotation. Got ${this.eyeRotation}, expected 0-7`);
Util.inRange(this.eyeType, Util.range(60)), assert.ok(Util.inRange(this.eyeSpacing, Util.range(13)), `Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`);
`Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`, 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.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.eyebrowColor, Util.range(8)), `Invalid Mii eyebrow color. Got ${this.eyebrowColor}, expected 0-7`);
assert.ok( assert.ok(Util.inRange(this.eyebrowScale, Util.range(9)), `Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`);
Util.inRange(this.eyebrowScale, Util.range(9)),
`Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`,
);
assert.ok( assert.ok(
Util.inRange(this.eyebrowVerticalStretch, Util.range(7)), Util.inRange(this.eyebrowVerticalStretch, Util.range(7)),
`Invalid Mii eyebrow vertical stretch. Got ${this.eyebrowVerticalStretch}, expected 0-6`, `Invalid Mii eyebrow vertical stretch. Got ${this.eyebrowVerticalStretch}, expected 0-6`,
); );
assert.ok( assert.ok(Util.inRange(this.eyebrowRotation, Util.range(12)), `Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`);
Util.inRange(this.eyebrowRotation, Util.range(12)), assert.ok(Util.inRange(this.eyebrowSpacing, Util.range(13)), `Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`);
`Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`, 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( assert.ok(Util.inRange(this.noseScale, Util.range(9)), `Invalid Mii nose scale. Got ${this.noseScale}, expected 0-8`);
Util.inRange(this.eyebrowSpacing, Util.range(13)), assert.ok(Util.inRange(this.noseYPosition, Util.range(19)), `Invalid Mii nose Y position. Got ${this.noseYPosition}, expected 0-18`);
`Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`, 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( assert.ok(Util.inRange(this.mouthScale, Util.range(9)), `Invalid Mii mouth scale. Got ${this.mouthScale}, expected 0-8`);
Util.inRange(this.eyebrowYPosition, Util.range(3, 19)), assert.ok(Util.inRange(this.mouthHorizontalStretch, Util.range(7)), `Invalid Mii mouth stretch. Got ${this.mouthHorizontalStretch}, expected 0-6`);
`Invalid Mii eyebrow Y position. Got ${this.eyebrowYPosition}, expected 3-18`, 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( assert.ok(Util.inRange(this.beardType, Util.range(6)), `Invalid Mii beard type. Got ${this.beardType}, expected 0-5`);
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.facialHairColor, Util.range(8)), `Invalid Mii beard type. Got ${this.facialHairColor}, expected 0-7`);
assert.ok( assert.ok(Util.inRange(this.mustacheScale, Util.range(9)), `Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`);
Util.inRange(this.mustacheScale, Util.range(9)), assert.ok(Util.inRange(this.mustacheYPosition, Util.range(17)), `Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`);
`Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`, 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( assert.ok(Util.inRange(this.glassesScale, Util.range(8)), `Invalid Mii glassess type. Got ${this.glassesScale}, expected 0-7`);
Util.inRange(this.mustacheYPosition, Util.range(17)), assert.ok(Util.inRange(this.glassesYPosition, Util.range(21)), `Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`);
`Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`, 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( assert.ok(Util.inRange(this.moleXPosition, Util.range(17)), `Invalid Mii mole X position. Got ${this.moleXPosition}, expected 0-16`);
Util.inRange(this.glassesType, Util.range(9)), assert.ok(Util.inRange(this.moleYPosition, Util.range(31)), `Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`);
`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 // Sanity checks
/* /*
@ -459,10 +246,7 @@ export default class Mii {
} }
*/ */
if ( if (this.nonUserMii && (this.creationTime !== 0 || this.isValid || this.dsMii || this.normalMii)) {
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"); 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 { public calculateCRC(): number {
// #view is inaccessible // #view is inaccessible
const data = new Uint8Array( const data = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset, this.buffer.length).subarray(0, 0x5e);
this.buffer.buffer,
this.buffer.byteOffset,
this.buffer.length,
).subarray(0, 0x5e);
let crc = 0x0000; let crc = 0x0000;
@ -727,23 +507,11 @@ export default class Mii {
data: this.encodeStudio().toString("hex"), data: this.encodeStudio().toString("hex"),
}; };
params.type = STUDIO_RENDER_TYPES.includes(params.type as string) params.type = STUDIO_RENDER_TYPES.includes(params.type as string) ? params.type : STUDIO_RENDER_DEFAULTS.type;
? params.type params.expression = STUDIO_RENDER_EXPRESSIONS.includes(params.expression as string) ? params.expression : STUDIO_RENDER_DEFAULTS.expression;
: 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.width = Util.clamp(params.width, 512);
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string) params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string) ? params.bgColor : STUDIO_RENDER_DEFAULTS.bgColor;
? params.bgColor params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(params.clothesColor) ? params.clothesColor : STUDIO_RENDER_DEFAULTS.clothesColor;
: 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.cameraXRotate = Util.clamp(params.cameraXRotate, 359);
params.cameraYRotate = Util.clamp(params.cameraYRotate, 359); params.cameraYRotate = Util.clamp(params.cameraYRotate, 359);
params.cameraZRotate = Util.clamp(params.cameraZRotate, 359); params.cameraZRotate = Util.clamp(params.cameraZRotate, 359);
@ -753,25 +521,16 @@ export default class Mii {
params.lightXDirection = Util.clamp(params.lightXDirection, 359); params.lightXDirection = Util.clamp(params.lightXDirection, 359);
params.lightYDirection = Util.clamp(params.lightYDirection, 359); params.lightYDirection = Util.clamp(params.lightYDirection, 359);
params.lightZDirection = Util.clamp(params.lightZDirection, 359); params.lightZDirection = Util.clamp(params.lightZDirection, 359);
params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes( params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes(params.lightDirectionMode)
params.lightDirectionMode,
)
? params.lightDirectionMode ? params.lightDirectionMode
: STUDIO_RENDER_DEFAULTS.lightDirectionMode; : STUDIO_RENDER_DEFAULTS.lightDirectionMode;
params.instanceCount = Util.clamp(params.instanceCount, 1, 16); params.instanceCount = Util.clamp(params.instanceCount, 1, 16);
params.instanceRotationMode = params.instanceRotationMode = STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(params.instanceRotationMode)
STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(
params.instanceRotationMode,
)
? params.instanceRotationMode ? params.instanceRotationMode
: STUDIO_RENDER_DEFAULTS.instanceRotationMode; : STUDIO_RENDER_DEFAULTS.instanceRotationMode;
// converts non-string params to strings // converts non-string params to strings
const query = new URLSearchParams( const query = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([key, value]) => [key, value.toString()])));
Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value.toString()]),
),
);
if (params.lightDirectionMode === "none") { if (params.lightDirectionMode === "none") {
query.delete("lightDirectionMode"); 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" // 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} */ /** Private _ctrMode function defined here: {@link https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L194} */
const sjclCcmCtrMode: (( const sjclCcmCtrMode:
prf: sjcl.SjclCipher, data: sjcl.BitArray, iv: sjcl.BitArray, | ((prf: sjcl.SjclCipher, data: sjcl.BitArray, iv: sjcl.BitArray, tag: sjcl.BitArray, tlen: number, L: number) => { data: sjcl.BitArray; tag: sjcl.BitArray })
tag: sjcl.BitArray, tlen: number, L: number | undefined =
) => { data: sjcl.BitArray; tag: sjcl.BitArray }) | undefined =
// @ts-expect-error -- Referencing a private function that is not in the types. // @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 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. // Decrypt 96 byte 3DS/Wii U format Mii data from the QR code.
// References (Credits: jaames, kazuki-4ys): // References (Credits: jaames, kazuki-4ys):
// - https://gist.github.com/jaames/96ce8daa11b61b758b6b0227b55f9f78 // - 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. // Check that the private _ctrMode function is defined.
if (!sjclCcmCtrMode) { 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. // 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. // 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 // 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 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 // remove tag from out, tag length = 128
sjcl.bitArray.bitLength(encryptedBits) - tlen); sjcl.bitArray.bitLength(encryptedBits) - tlen,
);
let decryptedBits: { data: sjcl.BitArray }; let decryptedBits: { data: sjcl.BitArray };
try { try {

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { createClient, RedisClientType } from "redis"; import { createClient, RedisClientType } from "redis";
import * as Sentry from "@sentry/nextjs";
import { auth } from "./auth"; import { auth } from "./auth";
const WINDOW_SIZE = 60; const WINDOW_SIZE = 60;
@ -17,7 +18,10 @@ async function getRedisClient() {
client = createClient({ client = createClient({
url: process.env.REDIS_URL, 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(); await client.connect();
} }
return client; return client;
@ -67,6 +71,7 @@ export class RateLimit {
return { success, limit: this.maxRequests, remaining, expires: expireAt }; return { success, limit: this.maxRequests, remaining, expires: expireAt };
} catch (error) { } catch (error) {
console.error("Rate limit check failed", error); console.error("Rate limit check failed", error);
Sentry.captureException(error, { tags: { source: "rate-limit-check" } });
return { return {
success: false, success: false,
limit: this.maxRequests, 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 // Converts hair dye to studio color
// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/9906440c1dafbe3f40ac8b95e10a22ebd85b441e/assets/data-conversion.js#L282 // Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/9906440c1dafbe3f40ac8b95e10a22ebd85b441e/assets/data-conversion.js#L282
// (Credits to kat21) // (Credits to kat21)
const hairDyeConverter = [ 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];
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. // All possible values for 2-bit hair dye mode.
export enum HairDyeMode { export enum HairDyeMode {