feat: sentry

also:
- update privacy policy
- fix space missing in terms of service
- some image viewer style changes
This commit is contained in:
trafficlunar 2026-02-21 18:44:58 +00:00
parent 4ccd376c0c
commit df7901b525
26 changed files with 2044 additions and 804 deletions

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,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,6 +16,7 @@
"@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",
@ -53,7 +53,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.1.18",
"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

@ -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";
@ -25,9 +26,7 @@ const submitSchema = z.object({
name: nameSchema, name: nameSchema,
tags: tagsSchema, tags: tagsSchema,
description: z.string().trim().max(256).optional(), description: z.string().trim().max(256).optional(),
qrBytesRaw: z qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, {
.array(z.number(), { error: "A QR code is required" })
.length(372, {
error: "QR code size is not a valid Tomodachi Life QR code", error: "QR code size is not a valid Tomodachi Life QR code",
}), }),
image1: z.union([z.instanceof(File), z.any()]).optional(), image1: z.union([z.instanceof(File), z.any()]).optional(),
@ -37,19 +36,16 @@ const submitSchema = z.object({
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
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();
if (check) return check; if (check) return check;
const response = await fetch( const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`,
);
const { value } = await response.json(); const { value } = await response.json();
if (!value) if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409);
return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409);
// Parse data // Parse data
const formData = await request.formData(); const formData = await request.formData();
@ -59,11 +55,11 @@ 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) {
return rateLimit.sendResponse( Sentry.captureException(error, {
{ error: "Invalid JSON in tags or QR bytes" }, extra: { stage: "submit-json-parse" },
400, });
); return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
} }
const parsed = submitSchema.safeParse({ const parsed = submitSchema.safeParse({
@ -76,26 +72,13 @@ export async function POST(request: NextRequest) {
image3: formData.get("image3"), image3: formData.get("image3"),
}); });
if (!parsed.success) if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
return rateLimit.sendResponse( const { name: uncensoredName, tags: uncensoredTags, description: uncensoredDescription, qrBytesRaw, image1, image2, image3 } = parsed.data;
{ error: parsed.error.issues[0].message },
400,
);
const {
name: uncensoredName,
tags: uncensoredTags,
description: uncensoredDescription,
qrBytesRaw,
image1,
image2,
image3,
} = parsed.data;
// Censor potential inappropriate words // Censor potential inappropriate words
const name = profanity.censor(uncensoredName); const name = profanity.censor(uncensoredName);
const tags = uncensoredTags.map((t) => profanity.censor(t)); const tags = uncensoredTags.map((t) => profanity.censor(t));
const description = const description = uncensoredDescription && profanity.censor(uncensoredDescription);
uncensoredDescription && profanity.censor(uncensoredDescription);
// Validate image files // Validate image files
const images: File[] = []; const images: File[] = [];
@ -107,10 +90,7 @@ export async function POST(request: NextRequest) {
if (imageValidation.valid) { if (imageValidation.valid) {
images.push(img); images.push(img);
} else { } else {
return rateLimit.sendResponse( return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
{ error: imageValidation.error },
imageValidation.status ?? 400,
);
} }
} }
@ -121,7 +101,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);
} }
// Create Mii in database // Create Mii in database
@ -141,10 +122,7 @@ export async function POST(request: NextRequest) {
}); });
// Ensure directories exist // Ensure directories exist
const miiUploadsDirectory = path.join( const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString());
uploadsDirectory,
miiRecord.id.toString(),
);
await fs.mkdir(miiUploadsDirectory, { recursive: true }); await fs.mkdir(miiUploadsDirectory, { recursive: true });
// Download the image of the Mii // Download the image of the Mii
@ -164,17 +142,13 @@ 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 Mii image:", error); console.error("Failed to download Mii image:", error);
return rateLimit.sendResponse( Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "studio-image-download" } });
{ error: "Failed to download Mii image" }, return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500);
500,
);
} }
try { try {
// Compress and store // Compress and store
const studioWebpBuffer = await sharp(studioBuffer) const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer();
.webp({ quality: 85 })
.toBuffer();
const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp"); const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp");
await fs.writeFile(studioFileLocation, studioWebpBuffer); await fs.writeFile(studioFileLocation, studioWebpBuffer);
@ -191,9 +165,7 @@ export async function POST(request: NextRequest) {
const codeBuffer = Buffer.from(codeBase64, "base64"); const codeBuffer = Buffer.from(codeBase64, "base64");
// Compress and store // Compress and store
const codeWebpBuffer = await sharp(codeBuffer) const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer();
.webp({ quality: 85 })
.toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer); await fs.writeFile(codeFileLocation, codeWebpBuffer);
@ -203,10 +175,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);
return rateLimit.sendResponse( Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "file-processing" } });
{ error: "Failed to process and store Mii files" }, return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
500,
);
} }
// Compress and store user images // Compress and store user images
@ -215,10 +185,7 @@ export async function POST(request: NextRequest) {
images.map(async (image, index) => { images.map(async (image, index) => {
const buffer = Buffer.from(await image.arrayBuffer()); const buffer = Buffer.from(await image.arrayBuffer());
const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer(); const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer();
const fileLocation = path.join( const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
miiUploadsDirectory,
`image${index}.webp`,
);
await fs.writeFile(fileLocation, webpBuffer); await fs.writeFile(fileLocation, webpBuffer);
}), }),
@ -235,10 +202,9 @@ export async function POST(request: NextRequest) {
}); });
} catch (error) { } catch (error) {
console.error("Error storing user images:", error); console.error("Error storing user images:", error);
return rateLimit.sendResponse(
{ error: "Failed to store user images" }, Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "user-image-storage" } });
500, return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
);
} }
return rateLimit.sendResponse({ success: true, id: miiRecord.id }); return rateLimit.sendResponse({ success: true, id: miiRecord.id });

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

@ -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

@ -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

@ -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

@ -36,9 +36,7 @@ export default function SubmitForm() {
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [studioUrl, setStudioUrl] = useState<string | undefined>(); const [studioUrl, setStudioUrl] = useState<string | undefined>();
const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState< const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState<string | undefined>();
string | undefined
>();
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
@ -129,29 +127,16 @@ export default function SubmitForm() {
<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={[studioUrl ?? "/loading.svg", generatedQrCodeUrl ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]} />
images={[
studioUrl ?? "/loading.svg",
generatedQrCodeUrl ?? "/loading.svg",
...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}>
{name || "Mii name"} {name || "Mii name"}
</h1> </h1>
<div id="tags" className="flex flex-wrap gap-1"> <div id="tags" className="flex flex-wrap gap-1">
{tags.length == 0 && ( {tags.length == 0 && <span className="px-2 py-1 bg-orange-300 rounded-full text-xs">tag</span>}
<span className="px-2 py-1 bg-orange-300 rounded-full text-xs">
tag
</span>
)}
{tags.map((tag) => ( {tags.map((tag) => (
<span <span key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
key={tag}
className="px-2 py-1 bg-orange-300 rounded-full text-xs"
>
{tag} {tag}
</span> </span>
))} ))}
@ -167,9 +152,7 @@ export default function SubmitForm() {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-2 max-w-2xl w-full"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-2 max-w-2xl w-full">
<div> <div>
<h2 className="text-2xl font-bold">Submit your Mii</h2> <h2 className="text-2xl font-bold">Submit your Mii</h2>
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">Share your creation for others to see.</p>
Share your creation for others to see.
</p>
</div> </div>
{/* Separator */} {/* Separator */}
@ -227,26 +210,15 @@ export default function SubmitForm() {
<QrUpload setQrBytesRaw={setQrBytesRaw} /> <QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span> <span>or</span>
<button <button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
type="button"
aria-label="Use your camera"
onClick={() => setIsQrScannerOpen(true)}
className="pill button gap-2"
>
<Icon icon="mdi:camera" fontSize={20} /> <Icon icon="mdi:camera" fontSize={20} />
Use your camera Use your camera
</button> </button>
<QrScanner <QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
isOpen={isQrScannerOpen}
setIsOpen={setIsQrScannerOpen}
setQrBytesRaw={setQrBytesRaw}
/>
<SubmitTutorialButton /> <SubmitTutorialButton />
<span className="text-xs text-zinc-400"> <span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
For emulators, aes_keys.txt is required.
</span>
</div> </div>
{/* Separator */} {/* Separator */}
@ -265,18 +237,14 @@ export default function SubmitForm() {
</p> </p>
</Dropzone> </Dropzone>
<span className="text-xs text-zinc-400 mt-2"> <span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
Animated images currently not supported.
</span>
</div> </div>
<ImageList files={files} setFiles={setFiles} /> <ImageList files={files} setFiles={setFiles} />
<hr className="border-zinc-300 my-2" /> <hr className="border-zinc-300 my-2" />
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
{error && ( {error && <span className="text-red-400 font-bold">Error: {error}</span>}
<span className="text-red-400 font-bold">Error: {error}</span>
)}
<SubmitButton onClick={handleSubmit} className="ml-auto" /> <SubmitButton onClick={handleSubmit} className="ml-auto" />
</div> </div>

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(),
]); ]);
@ -211,6 +215,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

@ -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,