mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-03-28 11:13:16 +00:00
feat: sentry
also: - update privacy policy - fix space missing in terms of service - some image viewer style changes
This commit is contained in:
parent
4ccd376c0c
commit
df7901b525
26 changed files with 2044 additions and 804 deletions
|
|
@ -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
2
.gitignore
vendored
|
|
@ -44,3 +44,5 @@ next-env.d.ts
|
||||||
|
|
||||||
# tomodachi-share
|
# tomodachi-share
|
||||||
uploads/
|
uploads/
|
||||||
|
# Sentry Config File
|
||||||
|
.env.sentry-build-plugin
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2299
pnpm-lock.yaml
2299
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
16
sentry.server.config.ts
Normal file
16
sentry.server.config.ts
Normal 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,
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
23
src/app/global-error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 'Delete Account' button. Upon
|
time by going to your profile page, clicking the settings icon, and clicking the 'Delete Account' 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>
|
||||||
|
|
|
||||||
|
|
@ -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 "as is" and without any warranties. We are not responsible for any user-generated content or the
|
This service is provided "as is" 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. "Mii" and all related character designs
|
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. "Mii" 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
18
src/instrumentation-client.ts
Normal file
18
src/instrumentation-client.ts
Normal 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
9
src/instrumentation.ts
Normal 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;
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
});
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue