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

2
.gitignore vendored
View file

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

View file

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

View file

@ -8,8 +8,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate",
"test": "vitest"
"postinstall": "prisma generate"
},
"dependencies": {
"@2toad/profanity": "^3.2.0",
@ -17,6 +16,7 @@
"@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.2",
"@sentry/nextjs": "^10.39.0",
"bit-buffer": "^0.3.0",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.19",
@ -53,7 +53,6 @@
"prisma": "^6.19.2",
"schema-dts": "^1.1.5",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"typescript": "^5.9.3"
}
}

File diff suppressed because it is too large Load diff

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
import fs from "fs/promises";
@ -25,9 +26,7 @@ const submitSchema = z.object({
name: nameSchema,
tags: tagsSchema,
description: z.string().trim().max(256).optional(),
qrBytesRaw: z
.array(z.number(), { error: "A QR code is required" })
.length(372, {
qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, {
error: "QR code size is not a valid Tomodachi Life QR code",
}),
image1: z.union([z.instanceof(File), z.any()]).optional(),
@ -37,19 +36,16 @@ const submitSchema = z.object({
export async function POST(request: NextRequest) {
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 check = await rateLimit.handle();
if (check) return check;
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`,
);
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
const { value } = await response.json();
if (!value)
return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409);
if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409);
// Parse data
const formData = await request.formData();
@ -59,11 +55,11 @@ export async function POST(request: NextRequest) {
try {
rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
} catch {
return rateLimit.sendResponse(
{ error: "Invalid JSON in tags or QR bytes" },
400,
);
} catch (error) {
Sentry.captureException(error, {
extra: { stage: "submit-json-parse" },
});
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
}
const parsed = submitSchema.safeParse({
@ -76,26 +72,13 @@ export async function POST(request: NextRequest) {
image3: formData.get("image3"),
});
if (!parsed.success)
return rateLimit.sendResponse(
{ error: parsed.error.issues[0].message },
400,
);
const {
name: uncensoredName,
tags: uncensoredTags,
description: uncensoredDescription,
qrBytesRaw,
image1,
image2,
image3,
} = parsed.data;
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const { name: uncensoredName, tags: uncensoredTags, description: uncensoredDescription, qrBytesRaw, image1, image2, image3 } = parsed.data;
// Censor potential inappropriate words
const name = profanity.censor(uncensoredName);
const tags = uncensoredTags.map((t) => profanity.censor(t));
const description =
uncensoredDescription && profanity.censor(uncensoredDescription);
const description = uncensoredDescription && profanity.censor(uncensoredDescription);
// Validate image files
const images: File[] = [];
@ -107,10 +90,7 @@ export async function POST(request: NextRequest) {
if (imageValidation.valid) {
images.push(img);
} else {
return rateLimit.sendResponse(
{ error: imageValidation.error },
imageValidation.status ?? 400,
);
return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
}
@ -121,7 +101,8 @@ export async function POST(request: NextRequest) {
try {
conversion = convertQrCode(qrBytes);
} catch (error) {
return rateLimit.sendResponse({ error }, 400);
Sentry.captureException(error, { extra: { stage: "qr-conversion" } });
return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
}
// Create Mii in database
@ -141,10 +122,7 @@ export async function POST(request: NextRequest) {
});
// Ensure directories exist
const miiUploadsDirectory = path.join(
uploadsDirectory,
miiRecord.id.toString(),
);
const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString());
await fs.mkdir(miiUploadsDirectory, { recursive: true });
// Download the image of the Mii
@ -164,17 +142,13 @@ export async function POST(request: NextRequest) {
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Failed to download Mii image:", error);
return rateLimit.sendResponse(
{ error: "Failed to download Mii image" },
500,
);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "studio-image-download" } });
return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500);
}
try {
// Compress and store
const studioWebpBuffer = await sharp(studioBuffer)
.webp({ quality: 85 })
.toBuffer();
const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer();
const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp");
await fs.writeFile(studioFileLocation, studioWebpBuffer);
@ -191,9 +165,7 @@ export async function POST(request: NextRequest) {
const codeBuffer = Buffer.from(codeBase64, "base64");
// Compress and store
const codeWebpBuffer = await sharp(codeBuffer)
.webp({ quality: 85 })
.toBuffer();
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer);
@ -203,10 +175,8 @@ export async function POST(request: NextRequest) {
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error processing Mii files:", error);
return rateLimit.sendResponse(
{ error: "Failed to process and store Mii files" },
500,
);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "file-processing" } });
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
}
// Compress and store user images
@ -215,10 +185,7 @@ export async function POST(request: NextRequest) {
images.map(async (image, index) => {
const buffer = Buffer.from(await image.arrayBuffer());
const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer();
const fileLocation = path.join(
miiUploadsDirectory,
`image${index}.webp`,
);
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
await fs.writeFile(fileLocation, webpBuffer);
}),
@ -235,10 +202,9 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
console.error("Error storing user images:", error);
return rateLimit.sendResponse(
{ error: "Failed to store user images" },
500,
);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "user-image-storage" } });
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
}
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">
<h1 className="text-2xl font-bold">Privacy Policy</h1>
<h2 className="font-light">
<strong className="font-medium">Effective Date:</strong> April 06, 2025
<strong className="font-medium">Effective Date:</strong> 21 February 2026
</h2>
<hr className="border-black/20 mt-1 mb-4" />
@ -32,12 +32,11 @@ export default function PrivacyPage() {
<p className="mb-2">The following types of information are stored when you use this website:</p>
<ul className="list-disc list-inside">
<li>
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your username, e-mail, and profile picture
are collected. Your authentication tokens may also be temporarily stored to maintain your login session.
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your username, e-mail, and profile picture are
collected. Your authentication tokens may also be temporarily stored to maintain your login session.
</li>
<li>
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom
images).
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom images).
</li>
<li>
<strong>Interaction Data:</strong> The Miis you like.
@ -49,9 +48,7 @@ export default function PrivacyPage() {
<h3 className="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3>
<section>
<p className="mb-2">
Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.
</p>
<p className="mb-2">Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.</p>
</section>
</li>
<li>
@ -63,18 +60,35 @@ export default function PrivacyPage() {
<a href="https://umami.is/" className="text-blue-700">
Umami
</a>{" "}
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable
information is collected through this service.
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable information is
collected through this service.
</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Error Reporting</h3>
<section>
<p className="mb-2">
This website uses{" "}
<a href="https://glitchtip.com/" className="text-blue-700">
GlitchTip
</a>{" "}
(a self-hosted Sentry-like instance) to monitor errors and site performance. To protect your privacy:
</p>
<ul className="list-disc list-inside ml-4">
<li>Errors and performance data is collected.</li>
<li>Only your user ID and username are sent, no other personally identifiable information is collected.</li>
<li>You can use ad blockers or browser privacy features to opt out.</li>
</ul>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Data Sharing</h3>
<section>
<p className="mb-2">
We do not sell your personal data to third parties. Your data may be sent anonymously to self-hosted third-party services or trusted
third-party tools (such as analytics) but these services are used solely to keep the site functional.
We do not sell your personal data to third parties. Your data may be sent anonymously to self-hosted third-party services or trusted third-party
tools (such as analytics) but these services are used solely to keep the site functional.
</p>
</section>
</li>
@ -95,9 +109,9 @@ export default function PrivacyPage() {
<section>
<p className="mb-2">
Your data, including your Miis, will be retained for as long as you have an account on the site. You may request that your data be
deleted at any time by going to your profile page, clicking the settings icon, and clicking the &apos;Delete Account&apos; button. Upon
clicking, your data will be promptly removed from our servers.
Your data, including your Miis, will be retained for as long as you have an account on the site. You may request that your data be deleted at any
time by going to your profile page, clicking the settings icon, and clicking the &apos;Delete Account&apos; button. Upon clicking, your data will
be promptly removed from our servers.
</p>
</section>
</li>
@ -106,8 +120,7 @@ export default function PrivacyPage() {
<section>
<p className="mb-2">
This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your
privacy.
This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your privacy.
</p>
</section>
</li>

View file

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

View file

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

View file

@ -36,9 +36,7 @@ export default function SubmitForm() {
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [studioUrl, setStudioUrl] = useState<string | undefined>();
const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState<
string | undefined
>();
const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState<string | 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">
<div className="flex justify-center">
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel
images={[
studioUrl ?? "/loading.svg",
generatedQrCodeUrl ?? "/loading.svg",
...files.map((file) => URL.createObjectURL(file)),
]}
/>
<Carousel images={[studioUrl ?? "/loading.svg", generatedQrCodeUrl ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]} />
<div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
{name || "Mii name"}
</h1>
<div id="tags" className="flex flex-wrap gap-1">
{tags.length == 0 && (
<span className="px-2 py-1 bg-orange-300 rounded-full text-xs">
tag
</span>
)}
{tags.length == 0 && <span className="px-2 py-1 bg-orange-300 rounded-full text-xs">tag</span>}
{tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-orange-300 rounded-full text-xs"
>
<span key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
{tag}
</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>
<h2 className="text-2xl font-bold">Submit your Mii</h2>
<p className="text-sm text-zinc-500">
Share your creation for others to see.
</p>
<p className="text-sm text-zinc-500">Share your creation for others to see.</p>
</div>
{/* Separator */}
@ -227,26 +210,15 @@ export default function SubmitForm() {
<QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span>
<button
type="button"
aria-label="Use your camera"
onClick={() => setIsQrScannerOpen(true)}
className="pill button gap-2"
>
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} />
Use your camera
</button>
<QrScanner
isOpen={isQrScannerOpen}
setIsOpen={setIsQrScannerOpen}
setQrBytesRaw={setQrBytesRaw}
/>
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
<SubmitTutorialButton />
<span className="text-xs text-zinc-400">
For emulators, aes_keys.txt is required.
</span>
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
</div>
{/* Separator */}
@ -265,18 +237,14 @@ export default function SubmitForm() {
</p>
</Dropzone>
<span className="text-xs text-zinc-400 mt-2">
Animated images currently not supported.
</span>
<span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
</div>
<ImageList files={files} setFiles={setFiles} />
<hr className="border-zinc-300 my-2" />
<div className="flex justify-between items-center">
{error && (
<span className="text-red-400 font-bold">Error: {error}</span>
)}
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</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 */
import type { ReactNode } from "react";
import * as Sentry from "@sentry/nextjs";
import fs from "fs/promises";
import path from "path";
@ -62,6 +63,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
if (!moderationResponse.ok) {
console.error("Moderation API error");
Sentry.captureException("Moderation API error", { extra: { stage: "moderation-api-response", status: moderationResponse.status } });
return { valid: false, error: "Content moderation check failed", status: 500 };
}
@ -71,13 +73,15 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
}
} catch (moderationError) {
console.error("Error fetching moderation API:", moderationError);
Sentry.captureException(moderationError, { extra: { stage: "moderation-api-fetch" } });
return { valid: false, error: "Moderation API is down", status: 503 };
}
return { valid: true };
} catch (error) {
console.error("Error validating image:", error);
return { valid: false, error: "Failed to process image file.", status: 500 };
Sentry.captureException(error, { extra: { stage: "image-validation" } });
return { valid: false, error: "Failed to process image file", status: 500 };
}
}
//#endregion
@ -117,7 +121,7 @@ const loadFonts = async (): Promise<Font[]> => {
};
}
return fontCache[weight]!;
})
}),
);
};
@ -131,13 +135,13 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
sharp(buffer)
.png()
.toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`)
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
),
fs.readFile(path.join(miiUploadsDirectory, "qr-code.webp")).then((buffer) =>
sharp(buffer)
.png()
.toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`)
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
),
loadFonts(),
]);
@ -211,6 +215,7 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
await fs.writeFile(fileLocation, buffer);
} catch (error) {
console.error("Error storing 'metadata' image type", error);
Sentry.captureException(error, { extra: { stage: "metadata-image-storage", miiId: mii.id } });
return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
}

View file

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

View file

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

View file

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