feat: astro test

This commit is contained in:
trafficlunar 2026-04-16 22:32:08 +01:00
parent df6e31ba89
commit 84144c383c
262 changed files with 18993 additions and 2655 deletions

11
backend/.dockerignore Normal file
View file

@ -0,0 +1,11 @@
.next
node_modules
.git
uploads
Dockerfile
.dockerignore
.gitignore
README.md
DEVELOPMENT.md
LICENSE
.env*

30
backend/.env.example Normal file
View file

@ -0,0 +1,30 @@
# Your PostgreSQL database
DATABASE_URL="postgresql://postgres:frieren@localhost:5432/tomodachi-share?schema=public"
# Used for rate limiting
REDIS_URL="redis://localhost:6379/0"
# Used for metadata, sitemaps, etc.
NEXT_PUBLIC_BASE_URL=http://localhost:3000
FRONTEND_URL=http://localhost:4321
CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX
CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX
# 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
AUTH_SECRET=XXXXXXXXXXXXXXXX
AUTH_DISCORD_ID=XXXXXXXXXXXXXXXX
AUTH_DISCORD_SECRET=XXXXXXXXXXXXXXXX
AUTH_GITHUB_ID=XXXXXXXXXXXXXXXX
AUTH_GITHUB_SECRET=XXXXXXXXXXXXXXXX
AUTH_GOOGLE_ID=XXXXXXXXXXXXXXXX
AUTH_GOOGLE_SECRET=XXXXXXXXXXXXXXXX
# Currently only supports one admin
NEXT_PUBLIC_ADMIN_USER_ID=1
# Separated by commas
NEXT_PUBLIC_CONTRIBUTORS_USER_IDS=176
# Sends notifications (such as admin reports) to ntfy
NTFY_URL="https://ntfy.yourdomain.com/tomodachi-share"

51
backend/Dockerfile Normal file
View file

@ -0,0 +1,51 @@
FROM node:23-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
COPY prisma ./prisma/
RUN corepack enable pnpm && pnpm i --frozen-lockfile
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable pnpm && pnpm prisma generate
RUN pnpm prisma migrate deploy
RUN pnpm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Create the uploads directory and set ownership
RUN mkdir -p /app/uploads && chown -R nextjs:nodejs /app/uploads
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

14
backend/eslint.config.mjs Normal file
View file

@ -0,0 +1,14 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
export default eslintConfig;

22
backend/next.config.ts Normal file
View file

@ -0,0 +1,22 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
unoptimized: true,
},
async headers() {
return [
{
source: "/api/:path*",
headers: [
{ key: "Access-Control-Allow-Origin", value: process.env.FRONTEND_URL || "http://localhost:4321" },
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST,PATCH,DELETE,OPTIONS" },
],
},
];
},
};
export default nextConfig;

63
backend/package.json Normal file
View file

@ -0,0 +1,63 @@
{
"name": "@tomodachi-share/backend",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate"
},
"dependencies": {
"@2toad/profanity": "^3.3.0",
"@auth/prisma-adapter": "2.11.1",
"@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.2",
"bit-buffer": "^0.3.0",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.20",
"downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0",
"file-type": "^22.0.1",
"jsqr": "^1.4.0",
"next": "16.2.3",
"next-auth": "5.0.0-beta.30",
"qrcode-generator": "^2.0.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10",
"redis": "^5.11.0",
"satori": "^0.26.0",
"seedrandom": "^3.0.5",
"sharp": "^0.34.5",
"sjcl-with-all": "1.0.8",
"swr": "^2.4.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34",
"eslint": "^10.2.0",
"eslint-config-next": "16.2.3",
"prisma": "^6.19.2",
"schema-dts": "^2.0.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"@tomodachi-share/shared": "workspace:*"
},
"exports": {
".": "./src/types.d.ts"
},
"types": "./src/types.d.ts"
}

5082
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View file

@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"username" TEXT,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"usernameUpdatedAt" TIMESTAMP(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"userId" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("provider","providerAccountId")
);
-- CreateTable
CREATE TABLE "sessions" (
"sessionToken" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "miis" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"name" VARCHAR(64) NOT NULL,
"imageCount" INTEGER NOT NULL DEFAULT 0,
"tags" TEXT[],
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"islandName" TEXT NOT NULL,
"allowedCopying" BOOLEAN NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "miis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "likes" (
"userId" INTEGER NOT NULL,
"miiId" INTEGER NOT NULL,
CONSTRAINT "likes_pkey" PRIMARY KEY ("userId","miiId")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_sessionToken_key" ON "sessions"("sessionToken");
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "miis" ADD CONSTRAINT "miis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "likes" ADD CONSTRAINT "likes_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "likes" ADD CONSTRAINT "likes_miiId_fkey" FOREIGN KEY ("miiId") REFERENCES "miis"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,25 @@
-- CreateEnum
CREATE TYPE "ReportType" AS ENUM ('MII', 'USER');
-- CreateEnum
CREATE TYPE "ReportReason" AS ENUM ('INAPPROPRIATE', 'SPAM', 'COPYRIGHT', 'OTHER');
-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('OPEN', 'RESOLVED', 'DISMISSED');
-- CreateTable
CREATE TABLE "reports" (
"id" SERIAL NOT NULL,
"reportType" "ReportType" NOT NULL,
"status" "ReportStatus" NOT NULL DEFAULT 'OPEN',
"targetId" INTEGER NOT NULL,
"reason" "ReportReason" NOT NULL,
"reasonNotes" TEXT,
"authorId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reports_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "reports" ADD CONSTRAINT "reports_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "MiiGender" AS ENUM ('MALE', 'FEMALE');
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "gender" "MiiGender";

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "description" VARCHAR(256);

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "imageUpdatedAt" TIMESTAMP(3);

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "reports" ADD COLUMN "creatorId" INTEGER;
-- AddForeignKey
ALTER TABLE "reports" ADD CONSTRAINT "reports_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,36 @@
-- CreateEnum
CREATE TYPE "PunishmentType" AS ENUM ('WARNING', 'TEMP_EXILE', 'PERM_EXILE');
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "punishmentId" INTEGER;
-- CreateTable
CREATE TABLE "mii_punishments" (
"punishmentId" INTEGER NOT NULL,
"miiId" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
CONSTRAINT "mii_punishments_pkey" PRIMARY KEY ("punishmentId","miiId")
);
-- CreateTable
CREATE TABLE "punishments" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"type" "PunishmentType" NOT NULL,
"notes" TEXT NOT NULL,
"reasons" TEXT[],
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "punishments_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "mii_punishments" ADD CONSTRAINT "mii_punishments_punishmentId_fkey" FOREIGN KEY ("punishmentId") REFERENCES "punishments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mii_punishments" ADD CONSTRAINT "mii_punishments_miiId_fkey" FOREIGN KEY ("miiId") REFERENCES "miis"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "punishments" ADD CONSTRAINT "punishments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "punishments" ADD COLUMN "returned" BOOLEAN NOT NULL DEFAULT false;

View file

@ -0,0 +1,9 @@
-- CreateEnum
CREATE TYPE "public"."MiiPlatform" AS ENUM ('SWITCH', 'THREE_DS');
-- AlterTable
ALTER TABLE "public"."miis" ADD COLUMN "platform" "public"."MiiPlatform" NOT NULL DEFAULT 'THREE_DS',
ALTER COLUMN "firstName" DROP NOT NULL,
ALTER COLUMN "lastName" DROP NOT NULL,
ALTER COLUMN "islandName" DROP NOT NULL,
ALTER COLUMN "allowedCopying" DROP NOT NULL;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "description" VARCHAR(256);

View file

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "MiiGender" ADD VALUE 'NONBINARY';

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "instructions" JSONB;

View file

@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `username` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `usernameUpdatedAt` on the `users` table. All the data in the column will be lost.
*/
-- DropIndex
DROP INDEX "users_username_key";
-- AlterTable
ALTER TABLE "miis" ALTER COLUMN "allowedCopying" DROP NOT NULL;
-- AlterTable
ALTER TABLE "users" DROP COLUMN "username",
DROP COLUMN "usernameUpdatedAt";

View file

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ReportReason" ADD VALUE 'BAD_QUALITY';

View file

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "MiiMakeup" AS ENUM ('FULL', 'PARTIAL', 'NONE');
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "makeup" "MiiMakeup";

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "miis" ALTER COLUMN "description" SET DATA TYPE VARCHAR(512);
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "description" SET DATA TYPE VARCHAR(512);

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "quarantined" BOOLEAN NOT NULL DEFAULT false;

View file

@ -0,0 +1,26 @@
-- CreateIndex
CREATE INDEX "likes_miiId_idx" ON "likes"("miiId");
-- CreateIndex
CREATE INDEX "miis_tags_idx" ON "miis" USING GIN ("tags");
-- CreateIndex
CREATE INDEX "miis_createdAt_idx" ON "miis"("createdAt");
-- CreateIndex
CREATE INDEX "miis_quarantined_createdAt_idx" ON "miis"("quarantined", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "miis_platform_createdAt_idx" ON "miis"("platform", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "miis_userId_createdAt_idx" ON "miis"("userId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "miis_gender_idx" ON "miis"("gender");
-- CreateIndex
CREATE INDEX "miis_makeup_idx" ON "miis"("makeup");
-- CreateIndex
CREATE INDEX "miis_quarantined_id_idx" ON "miis"("quarantined", "id");

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "in_queue" BOOLEAN NOT NULL DEFAULT false;

View file

@ -0,0 +1,14 @@
/*
Warnings:
- The values [COPYRIGHT] on the enum `ReportReason` will be removed. If these variants are still used in the database, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "ReportReason_new" AS ENUM ('INAPPROPRIATE', 'SPAM', 'BAD_QUALITY', 'OTHER');
ALTER TABLE "reports" ALTER COLUMN "reason" TYPE "ReportReason_new" USING ("reason"::text::"ReportReason_new");
ALTER TYPE "ReportReason" RENAME TO "ReportReason_old";
ALTER TYPE "ReportReason_new" RENAME TO "ReportReason";
DROP TYPE "public"."ReportReason_old";
COMMIT;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "youtubeId" TEXT;

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View file

@ -0,0 +1,211 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
emailVerified DateTime?
image String?
description String? @db.VarChar(512)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
imageUpdatedAt DateTime?
accounts Account[]
sessions Session[]
miis Mii[]
likes Like[]
reportsAuthored Report[] @relation("ReportAuthor")
reports Report[] @relation("ReportTargetCreator")
punishments Punishment[]
@@map("users")
}
model Account {
userId Int
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
@@map("accounts")
}
model Session {
sessionToken String @unique
userId Int
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("sessions")
}
model Mii {
id Int @id @default(autoincrement())
userId Int
name String @db.VarChar(64)
imageCount Int @default(0)
tags String[]
description String? @db.VarChar(512)
platform MiiPlatform @default(THREE_DS)
quarantined Boolean @default(false)
in_queue Boolean @default(false)
instructions Json?
youtubeId String?
gender MiiGender?
makeup MiiMakeup?
firstName String?
lastName String?
islandName String?
allowedCopying Boolean?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
likedBy Like[]
punishmentId Int?
punishments MiiPunishment[]
@@index([tags], type: Gin)
@@index([createdAt])
@@index([quarantined, createdAt(sort: Desc)])
@@index([platform, createdAt(sort: Desc)])
@@index([userId, createdAt(sort: Desc)])
@@index([gender])
@@index([makeup])
@@index([quarantined, id])
@@map("miis")
}
model Like {
userId Int
miiId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
mii Mii @relation(fields: [miiId], references: [id], onDelete: Cascade)
@@id([userId, miiId])
@@index([miiId])
@@map("likes")
}
model Report {
id Int @id @default(autoincrement())
reportType ReportType
status ReportStatus @default(OPEN)
targetId Int
reason ReportReason
reasonNotes String?
createdAt DateTime @default(now())
// note: this refers to the person who made the report
authorId Int?
author User? @relation("ReportAuthor", fields: [authorId], references: [id])
creatorId Int?
creator User? @relation("ReportTargetCreator", fields: [creatorId], references: [id])
@@map("reports")
}
model MiiPunishment {
punishmentId Int
miiId Int
reason String
punishment Punishment @relation(fields: [punishmentId], references: [id], onDelete: Cascade)
mii Mii @relation(fields: [miiId], references: [id], onDelete: Cascade)
@@id([punishmentId, miiId])
@@map("mii_punishments")
}
model Punishment {
id Int @id @default(autoincrement())
userId Int
type PunishmentType
returned Boolean @default(false)
notes String
reasons String[]
violatingMiis MiiPunishment[]
expiresAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@map("punishments")
}
enum MiiPlatform {
SWITCH
THREE_DS // can't start with a number
}
enum MiiGender {
MALE
FEMALE
NONBINARY
}
enum MiiMakeup {
FULL
PARTIAL
NONE
}
enum ReportType {
MII
USER
}
enum ReportReason {
INAPPROPRIATE
SPAM
BAD_QUALITY
OTHER
}
enum ReportStatus {
OPEN
RESOLVED
DISMISSED
}
enum PunishmentType {
WARNING
TEMP_EXILE
PERM_EXILE
}

View file

@ -0,0 +1 @@
These fonts are used for generating the 'metadata' image type for Miis (the images you should see in search engines!)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
backend/public/logo.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="130.734" height="105.615" viewBox="0 0 34.59 27.944"><rect width="32.208" height="25.562" x="1.191" y="1.191" rx="1.874" fill="#f8f8f8" stroke="#ff8904" stroke-width="2.381" paint-order="stroke fill markers"/><rect width="29.369" height="22.49" x="2.611" y="2.727" rx=".966" fill="#c8c8c8" paint-order="stroke fill markers"/><g fill="#fef3c6"><rect width="13.371" height="20.989" x="17.918" y="3.478" rx=".423" paint-order="stroke fill markers"/><rect width="13.371" height="20.989" x="3.301" y="3.478" rx=".423" paint-order="stroke fill markers"/></g><g fill="#ff8904"><use href="#B" paint-order="stroke fill markers"/><circle cx="9.986" cy="13.076" r="5.512" paint-order="stroke fill markers"/><use href="#B" x="14.204" y="-0.093" paint-order="stroke fill markers"/><circle cx="24.191" cy="12.983" r="5.512" paint-order="stroke fill markers"/></g><g fill="none" stroke="#c8c8c8" stroke-linejoin="round"><rect width="13.791" height="20.704" x="17.295" y="3.62" ry="1.146" rx="1.095" stroke-width="1.786" paint-order="stroke fill markers"/><rect width="13.366" height="21.167" x="3.301" y="3.389" ry="1.146" rx="1.095" stroke-width="1.323" paint-order="stroke fill markers"/></g><defs ><path id="B" d="M15.03 24.516c0-2.307-.961-4.439-2.522-5.592s-3.483-1.153-5.044 0-2.522 3.285-2.522 5.592h5.044z"/></defs></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@tomodachi-share/shared/schemas";
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const searchParams = request.nextUrl.searchParams;
const parsedMiiId = idSchema.safeParse(searchParams.get("id"));
if (!parsedMiiId.success) return NextResponse.json({ error: parsedMiiId.error.issues[0].message }, { status: 400 });
const miiId = parsedMiiId.data;
await prisma.mii.update({
where: {
id: miiId,
},
data: {
in_queue: false,
},
});
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
let bannerText: string | null = null;
export async function GET() {
return NextResponse.json({ success: true, message: bannerText });
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await request.text();
bannerText = body;
return NextResponse.json({ success: true });
}
export async function DELETE() {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
bannerText = null;
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { settings } from "@/lib/settings";
export async function GET() {
return NextResponse.json({ success: true, value: settings.canSubmit });
}
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await request.json();
const validated = z.boolean().safeParse(body);
if (!validated.success) return NextResponse.json({ error: "Failed to validate body" }, { status: 400 });
settings.canSubmit = validated.data;
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@tomodachi-share/shared/schemas";
export async function GET(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const searchParams = request.nextUrl.searchParams;
const parsed = idSchema.safeParse(searchParams.get("id"));
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const userId = parsed.data;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
include: {
punishments: {
orderBy: {
createdAt: "desc",
},
select: {
id: true,
type: true,
returned: true,
notes: true,
reasons: true,
violatingMiis: {
select: {
miiId: true,
reason: true,
},
},
expiresAt: true,
createdAt: true,
},
},
},
});
if (!user) return NextResponse.json({ error: "No user found" }, { status: 404 });
return NextResponse.json({
success: true,
name: user.name,
image: user.image,
createdAt: user.createdAt,
punishments: user.punishments,
});
}

View file

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import dayjs from "dayjs";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@tomodachi-share/shared/schemas";
import { PunishmentType } from "@prisma/client";
const punishSchema = z.object({
type: z.enum([PunishmentType.WARNING, PunishmentType.TEMP_EXILE, PunishmentType.PERM_EXILE]),
duration: z
.number({ error: "Duration (days) must be a number" })
.int({ error: "Duration (days) must be an integer" })
.positive({ error: "Duration (days) must be valid" }),
notes: z.string(),
reasons: z.array(z.string()).optional(),
miiReasons: z
.array(
z.object({
id: z.number({ error: "Mii ID must be a number" }).int({ error: "Mii ID must be an integer" }).positive({ error: "Mii ID must be valid" }),
reason: z.string(),
}),
)
.optional(),
});
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const searchParams = request.nextUrl.searchParams;
const parsedUserId = idSchema.safeParse(searchParams.get("id"));
if (!parsedUserId.success) return NextResponse.json({ error: parsedUserId.error.issues[0].message }, { status: 400 });
const userId = parsedUserId.data;
const body = await request.json();
const parsed = punishSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const { type, duration, notes, reasons, miiReasons } = parsed.data;
const expiresAt = type === "TEMP_EXILE" ? dayjs().add(duration, "days").toDate() : null;
await prisma.punishment.create({
data: {
userId,
type: type as PunishmentType,
expiresAt,
notes,
reasons: reasons?.length !== 0 ? reasons : [],
violatingMiis: {
create: miiReasons?.map((mii) => ({
miiId: mii.id,
reason: mii.reason,
})),
},
},
});
return NextResponse.json({ success: true });
}
export async function DELETE(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const searchParams = request.nextUrl.searchParams;
const parsedPunishmentId = idSchema.safeParse(searchParams.get("id"));
if (!parsedPunishmentId.success) return NextResponse.json({ error: parsedPunishmentId.error.issues[0].message }, { status: 400 });
const punishmentId = parsedPunishmentId.data;
await prisma.punishment.delete({
where: {
id: punishmentId,
},
});
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { settings } from "@/lib/settings";
export async function GET() {
return NextResponse.json({ success: true, value: settings.queueEnabled });
}
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await request.json();
const validated = z.boolean().safeParse(body);
if (!validated.success) return NextResponse.json({ error: "Failed to validate body" }, { status: 400 });
settings.queueEnabled = validated.data;
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { generateMetadataImage } from "@/lib/images";
export async function PATCH() {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
// Start processing in background
regenerateImages().catch(console.error);
return NextResponse.json({ success: true });
}
async function regenerateImages() {
// Get miis in batches to reduce memory usage
const BATCH_SIZE = 10;
const totalMiis = await prisma.mii.count();
let processed = 0;
for (let skip = 0; skip < totalMiis; skip += BATCH_SIZE) {
const miis = await prisma.mii.findMany({
skip,
take: BATCH_SIZE,
include: { user: { select: { name: true } } },
});
// Process each batch sequentially to avoid overwhelming the server
for (const mii of miis) {
try {
await generateMetadataImage(mii, mii.user.name);
processed++;
} catch (error) {
console.error(`Failed to generate image for mii ${mii.id}:`, error);
}
}
}
}

View file

@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { profanity } from "@2toad/profanity";
import z from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
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 });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const { description } = await request.json();
if (!description) return rateLimit.sendResponse({ error: "New about me is required" }, 400);
const validation = z.string().trim().max(256).safeParse(description);
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
try {
await prisma.user.update({
where: { id: Number(session.user?.id) },
data: { description: profanity.censor(description) },
});
} catch (error) {
console.error("Failed to update description:", error);
return rateLimit.sendResponse({ error: "Failed to update description" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
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 });
const rateLimit = new RateLimit(request, 1);
const check = await rateLimit.handle();
if (check) return check;
try {
await prisma.user.delete({
where: { id: Number(session.user.id) },
});
} catch (error) {
console.error("Failed to delete user:", error);
return rateLimit.sendResponse({ error: "Failed to delete account" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { userNameSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const { name } = await request.json();
if (!name) return rateLimit.sendResponse({ error: "New name is required" }, 400);
const validation = userNameSchema.safeParse(name);
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
// Check for inappropriate words
if (profanity.exists(name)) return rateLimit.sendResponse({ error: "Name contains inappropriate words" }, 400);
try {
await prisma.user.update({
where: { id: Number(session.user.id) },
data: { name },
});
} catch (error) {
console.error("Failed to update name:", error);
return rateLimit.sendResponse({ error: "Failed to update name" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import dayjs from "dayjs";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
import { validateImage } from "@/lib/images";
const uploadsDirectory = path.join(process.cwd(), "uploads", "user");
const formDataSchema = z.object({
image: z.union([z.instanceof(File), z.any()]).optional(),
});
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
// Check if profile picture was updated in the last 7 days
const user = await prisma.user.findUnique({ where: { id: Number(session.user?.id) } });
if (user && user.imageUpdatedAt) {
const timePeriod = dayjs().subtract(7, "days");
const lastUpdate = dayjs(user.imageUpdatedAt);
if (lastUpdate.isAfter(timePeriod)) return rateLimit.sendResponse({ error: "Profile picture was changed in the last 7 days" }, 400);
}
// Parse data
const formData = await request.formData();
const parsed = formDataSchema.safeParse({
image: formData.get("image"),
});
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const { image } = parsed.data;
// If there is no image, set the profile picture to the guest image
if (!image) {
await prisma.user.update({
where: { id: Number(session.user?.id) },
data: { image: `/guest.png`, imageUpdatedAt: new Date() },
});
return rateLimit.sendResponse({ success: true });
}
// Validate image contents
const imageValidation = await validateImage(image);
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
// Ensure directories exist
await fs.mkdir(uploadsDirectory, { recursive: true });
try {
const buffer = Buffer.from(await image.arrayBuffer());
const pngBuffer = await sharp(buffer, { animated: true }).resize({ width: 128, height: 128 }).png({ quality: 85 }).toBuffer();
const fileLocation = path.join(uploadsDirectory, `${session.user?.id}.png`);
await fs.writeFile(fileLocation, pngBuffer);
} catch (error) {
console.error("Error uploading profile picture:", error);
return rateLimit.sendResponse({ error: "Failed to store profile picture" }, 500);
}
try {
await prisma.user.update({
where: { id: Number(session.user?.id) },
data: { image: `/profile/${session.user?.id}/picture`, imageUpdatedAt: new Date() },
});
} catch (error) {
console.error("Failed to update profile picture:", error);
return rateLimit.sendResponse({ error: "Failed to update profile picture" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -0,0 +1,6 @@
import { type NextRequest } from "next/server";
import { signIn } from "@/lib/auth";
export async function GET(req: NextRequest, { params }: { params: Promise<{ provider: string }> }) {
return signIn((await params).provider);
}

View file

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
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 });
const rateLimit = new RateLimit(request, 30, "/api/mii/delete");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
// Check ownership of Mii
const mii = await prisma.mii.findUnique({
where: {
id: miiId,
},
});
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
if (!(Number(session.user?.id) === mii.userId || Number(session.user?.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)))
return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403);
const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString());
try {
await prisma.mii.delete({
where: { id: miiId },
});
} catch (error) {
console.error("Failed to delete Mii from database:", error);
return rateLimit.sendResponse({ error: "Failed to delete Mii" }, 500);
}
try {
await fs.rm(miiUploadsDirectory, { recursive: true, force: true });
} catch (error) {
console.warn("Failed to delete Mii image files:", error);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -0,0 +1,258 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { MiiGender, MiiMakeup, Prisma } from "@prisma/client";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema, nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
import { generateMetadataImage, validateImage } from "@/lib/images";
import { RateLimit } from "@/lib/rate-limit";
import { minifyInstructions, SwitchMiiInstructions } from "@tomodachi-share/shared";
import { settings } from "@/lib/settings";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
const editSchema = z.object({
name: nameSchema.optional(),
tags: tagsSchema.optional(),
description: z.string().trim().max(512).optional(),
quarantined: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional(),
gender: z.enum(MiiGender).optional(),
makeup: z.enum(MiiMakeup).optional(),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
youtubeId: z
.string()
.regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID")
.or(z.literal(""))
.optional(),
instructions: switchMiiInstructionsSchema,
image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(),
image3: z.union([z.instanceof(File), z.any()]).optional(),
});
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 6); // no grouped pathname; edit each mii 2 times a minute
const check = await rateLimit.handle();
if (check) return check;
// Get Mii ID
const { id: slugId } = await params;
const parsedId = idSchema.safeParse(slugId);
if (!parsedId.success) return rateLimit.sendResponse({ error: parsedId.error.issues[0].message }, 400);
const miiId = parsedId.data;
// Check ownership of Mii
const mii = await prisma.mii.findUnique({
where: {
id: miiId,
},
});
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
if (!(Number(session.user?.id) === mii.userId || Number(session.user?.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)))
return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403);
// Parse form data
const formData = await request.formData();
let rawTags: string[] | undefined = undefined;
try {
const value = formData.get("tags");
if (value) rawTags = JSON.parse(value as string);
} catch {
return rateLimit.sendResponse({ error: "Invalid JSON in tags" }, 400);
}
let minifiedInstructions: Partial<SwitchMiiInstructions> | undefined;
if (mii.platform === "SWITCH")
minifiedInstructions = minifyInstructions(JSON.parse((formData.get("instructions") as string) ?? "{}") as SwitchMiiInstructions);
const parsed = editSchema.safeParse({
name: formData.get("name") ?? undefined,
tags: rawTags,
description: formData.get("description") ?? undefined,
quarantined: formData.get("quarantined") ?? undefined,
gender: formData.get("gender") ?? undefined,
makeup: formData.get("makeup") ?? undefined,
miiPortraitImage: formData.get("miiPortraitImage"),
miiFeaturesImage: formData.get("miiFeaturesImage"),
youtubeId: formData.get("youtubeId") ?? undefined,
instructions: minifiedInstructions,
image1: formData.get("image1"),
image2: formData.get("image2"),
image3: formData.get("image3"),
});
if (!parsed.success) {
const firstIssue = parsed.error.issues[0];
const path = firstIssue.path.length ? firstIssue.path.join(".") : "root";
const error = `${path}: ${firstIssue.message}`;
return rateLimit.sendResponse({ error }, 400);
}
const { name, tags, description, quarantined, gender, makeup, miiPortraitImage, miiFeaturesImage, youtubeId, instructions, image1, image2, image3 } =
parsed.data;
// Validate image files
const customImages: File[] = [];
for (const img of [image1, image2, image3]) {
if (!img) continue;
const validation = await validateImage(img);
if (validation.valid) {
customImages.push(img);
} else {
return rateLimit.sendResponse({ error: `Failed to verify custom image: ${validation.error}` }, validation.status ?? 400);
}
}
// Check Mii portrait & features image (Switch)
if (mii.platform === "SWITCH") {
if (miiPortraitImage) {
const validation = await validateImage(miiPortraitImage);
if (!validation.valid) return rateLimit.sendResponse({ error: `Failed to verify portrait: ${validation.error}` }, validation.status ?? 400);
}
if (miiFeaturesImage) {
const validation = await validateImage(miiFeaturesImage);
if (!validation.valid) return rateLimit.sendResponse({ error: `Failed to verify features: ${validation.error}` }, validation.status ?? 400);
}
}
// Prevent non-admins from quarantining Miis
if (quarantined && session.user?.id?.toString() !== process.env.NEXT_PUBLIC_ADMIN_USER_ID)
return rateLimit.sendResponse({ error: `You're not an admin!` }, 401);
// Edit Mii in database
const updateData: Prisma.MiiUpdateInput = {};
if (name !== undefined) updateData.name = profanity.censor(name); // Censor potentially inappropriate words
if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t));
if (description !== undefined) updateData.description = profanity.censor(description);
if (quarantined !== undefined) updateData.quarantined = quarantined;
if (mii.platform === "SWITCH" && gender !== undefined) updateData.gender = gender;
if (makeup !== undefined) updateData.makeup = makeup;
if (youtubeId !== undefined) updateData.youtubeId = youtubeId;
if (instructions !== undefined) updateData.instructions = instructions;
if (customImages.length > 0) updateData.imageCount = customImages.length;
const imagesChanged = customImages.length > 0 || miiPortraitImage || miiFeaturesImage;
if (settings.queueEnabled && imagesChanged) updateData.in_queue = true;
if (Object.keys(updateData).length === 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400);
const updatedMii = await prisma.mii.update({
where: {
id: miiId,
},
data: updateData,
include: {
user: {
select: {
name: true,
},
},
},
});
// Ensure directories exist
const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString());
await fs.mkdir(miiUploadsDirectory, { recursive: true });
// Only touch files if new images were uploaded
if (customImages.length > 0) {
// Delete all custom images
const files = await fs.readdir(miiUploadsDirectory);
await Promise.all(files.filter((file) => file.startsWith("image")).map((file) => fs.unlink(path.join(miiUploadsDirectory, file))));
// Compress and upload new images
try {
await Promise.all(
customImages.map(async (image, index) => {
const buffer = Buffer.from(await image.arrayBuffer());
const pngBuffer = await sharp(buffer).resize({ height: 800, fit: "inside", withoutEnlargement: true }).png({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, `image${index}.png`);
await fs.writeFile(fileLocation, pngBuffer);
}),
);
} catch (error) {
console.error("Error uploading user images:", error);
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
}
}
// Only save portrait & features for Switch Miis when they are provided
if (mii.platform === "SWITCH" && (miiPortraitImage || miiFeaturesImage)) {
try {
await Promise.all(
[
miiPortraitImage &&
(async () => {
const portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
const pngBuffer = await sharp(portraitBuffer)
.resize({
height: 500,
fit: "inside",
withoutEnlargement: true,
})
.png({ quality: 85 })
.toBuffer();
await fs.writeFile(path.join(miiUploadsDirectory, "mii.png"), pngBuffer);
})(),
miiFeaturesImage &&
(async () => {
const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer());
const pngBuffer = await sharp(featuresBuffer)
.resize({
height: 800,
fit: "inside",
withoutEnlargement: true,
})
.png({ quality: 85 })
.toBuffer();
await fs.writeFile(path.join(miiUploadsDirectory, "features.png"), pngBuffer);
})(),
].filter(Boolean),
);
} catch (error) {
console.error("Error uploading portrait/features images:", error);
return rateLimit.sendResponse({ error: "Failed to store portrait/features images" }, 500);
}
}
try {
await generateMetadataImage(updatedMii, updatedMii.user.name!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
}
// Tell Cloudflare to purge cache for the changed pages
fetch(`https://api.cloudflare.com/client/v4/zones/${process.env.CLOUDFLARE_ZONE_ID}/purge_cache`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({
files: [
`${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}`,
`${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}/image?type=mii`,
`${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}/image?type=features`,
],
}),
}).catch((err) => {
console.error("Cloudflare cache purge failed:", err);
});
return rateLimit.sendResponse({ success: true });
}

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@tomodachi-share/shared/schemas";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const miiId = parsed.data;
const mii = await prisma.mii.findUnique({
where: {
id: miiId,
},
include: {
user: {
select: {
name: true,
},
},
likedBy: session?.user
? {
where: {
userId: Number(session.user.id),
},
select: { userId: true },
}
: false,
_count: {
select: { likedBy: true }, // Get total like count
},
},
});
return NextResponse.json(mii);
}

View file

@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 100, "/api/mii/like");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
const result = await prisma.$transaction(async (tx) => {
const existingLike = await tx.like.findUnique({
where: {
userId_miiId: {
userId: Number(session.user?.id),
miiId,
},
},
});
if (existingLike) {
// Remove the like if it exists
await tx.like.delete({
where: {
userId_miiId: {
userId: Number(session.user?.id),
miiId,
},
},
});
} else {
// Add a like if it doesn't exist
await tx.like.create({
data: {
userId: Number(session.user?.id),
miiId,
},
});
}
const likeCount = await tx.like.count({
where: { miiId },
});
return { liked: !existingLike, count: likeCount };
});
return rateLimit.sendResponse({ success: true, liked: result.liked, count: result.count });
}

View file

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
export async function GET(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 50, "/api/mii/like_get");
const check = await rateLimit.handle();
if (check) return check;
const idsParam = new URL(request.url).searchParams.get("ids");
if (!idsParam) return NextResponse.json({ error: "Missing IDs parameter" }, { status: 400 });
const ids = idsParam.split(",").map(Number).filter(Boolean);
if (!ids.length) return NextResponse.json({ error: "No valid IDs provided" }, { status: 400 });
if (ids.length > 100) return NextResponse.json({ error: "Too many IDs, maximum is 100" }, { status: 400 });
const liked = await prisma.like.findMany({
where: { userId: Number(session.user?.id), miiId: { in: ids } },
select: { miiId: true },
});
// Return only Miis that are liked
return NextResponse.json(liked.map((l) => l.miiId));
}

View file

@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { searchSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { Prisma } from "@prisma/client";
import crypto from "crypto";
import seedrandom from "seedrandom";
export async function GET(request: NextRequest) {
const session = await auth();
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed, parentPage, userId } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
if (parentPage === "likes" && session?.user?.id) {
const likedMiis = await prisma.like.findMany({
where: { userId: Number(session.user.id) },
select: { miiId: true },
});
miiIdsLiked = likedMiis.map((like) => like.miiId);
}
const where: Prisma.MiiWhereInput = {
// In queue logic
...(parentPage === "admin"
? { in_queue: true } // Only show queued Miis
: userId
? {
// Include queued Miis if user is on their profile
...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
userId,
}
: {
// Don't show queued Miis on main page
in_queue: false,
}),
// Only show liked miis on likes page
...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
// Searching
...(query && {
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
}),
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// Platform
...(platform && { platform: { equals: platform } }),
// Gender
...(gender && { gender: { equals: gender } }),
// Allow Copying
...(allowCopying && { allowedCopying: true }),
// Makeup
...(makeup && { makeup: { equals: makeup } }),
// Quarantined
...(!quarantined && !userId && { quarantined: false }),
};
const select: Prisma.MiiSelect = {
id: true,
// Don't show when userId is specified
...(!userId && {
user: {
select: {
id: true,
name: true,
},
},
}),
platform: true,
name: true,
imageCount: true,
tags: true,
createdAt: true,
gender: true,
makeup: true,
allowedCopying: true,
quarantined: true,
in_queue: true,
// Mii liked check
...(session?.user?.id && {
likedBy: {
where: { userId: Number(session.user.id) },
select: { userId: true },
},
}),
// Like count
_count: {
select: { likedBy: true },
},
};
const skip = (page - 1) * limit;
let totalCount: number;
let filteredCount: number;
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
if (sort === "random") {
// Get all IDs that match the where conditions
const matchingIds = await prisma.mii.findMany({
where,
select: { id: true },
});
totalCount = matchingIds.length;
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
if (matchingIds.length === 0) return;
// Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
const rng = seedrandom(randomSeed.toString());
// Randomize all IDs using the Durstenfeld algorithm
for (let i = matchingIds.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
}
// Convert to number[] array
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
miis = await prisma.mii.findMany({
where: {
id: { in: selectedIds },
},
select,
});
} else {
// Sorting by likes, newest, or oldest
let orderBy: Prisma.MiiOrderByWithRelationInput[];
if (sort === "likes") {
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
} else if (sort === "oldest") {
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
} else {
// default to newest
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
}
[totalCount, filteredCount, miis] = await Promise.all([
prisma.mii.count({ where: { ...where } }), // TODO: User id
prisma.mii.count({ where, skip, take: limit }),
prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]);
}
const lastPage = Math.ceil(totalCount / limit);
return NextResponse.json({
miis,
totalCount,
filteredCount,
lastPage,
});
}

View file

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@tomodachi-share/shared/schemas";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const userId = parsed.data;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
include: {
_count: {
select: {
likes: true,
},
},
},
});
return NextResponse.json(user);
}

View file

@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { Prisma, ReportReason, ReportType } from "@prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
const reportSchema = z.object({
id: z.coerce.number({ error: "ID must be a number" }).int({ error: "ID must be an integer" }).positive({ error: "ID must be valid" }),
type: z.enum(["mii", "user"], { error: "Type must be either 'mii' or 'user'" }),
reason: z.enum(["inappropriate", "spam", "bad_quality", "other"], {
message: "Reason must be either 'inappropriate', 'spam', 'bad_quality' or 'other'",
}),
notes: z.string().trim().max(256).optional(),
});
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 2);
const check = await rateLimit.handle();
if (check) return check;
const body = await request.json();
const parsed = reportSchema.safeParse(body);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const { id, type, reason, notes } = parsed.data;
let mii: Prisma.MiiGetPayload<{
include: {
user: {
select: {
name: true;
};
};
};
}> | null = null;
// Check if the Mii or User exists
if (type === "mii") {
mii = await prisma.mii.findUnique({
where: { id },
include: {
user: {
select: {
name: true,
},
},
},
});
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
} else {
const user = await prisma.user.findUnique({
where: { id },
});
if (!user) return rateLimit.sendResponse({ error: "User not found" }, 404);
}
// Check if user creating the report has already reported the same target before
const existing = await prisma.report.findFirst({
where: {
targetId: id,
reportType: type.toUpperCase() as ReportType,
authorId: Number(session.user?.id),
},
});
if (existing) return rateLimit.sendResponse({ error: "You have already reported this" }, 400);
try {
await prisma.report.create({
data: {
reportType: type.toUpperCase() as ReportType,
targetId: id,
reason: reason.toUpperCase() as ReportReason,
reasonNotes: notes,
authorId: Number(session.user?.id),
creatorId: mii ? mii.userId : undefined,
},
});
} catch (error) {
console.error("Report creation failed", error);
return rateLimit.sendResponse({ error: "Failed to create report" }, 500);
}
// Send notification to ntfy
if (process.env.NTFY_URL) {
// This is only shown if report type is MII
const miiCreatorMessage = mii ? `by ${mii.user.name} (ID: ${mii.userId})` : "";
await fetch(process.env.NTFY_URL, {
method: "POST",
body: `Report by ${session.user?.name} (ID: ${session.user?.id}) on ${type.toUpperCase()} (ID: ${id}) ${miiCreatorMessage}`,
headers: {
Title: "Report recieved - TomodachiShare",
Priority: "urgent",
Tags: "triangular_flag_on_post",
},
});
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { RateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
export async function DELETE(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 1);
const check = await rateLimit.handle();
if (check) return check;
const activePunishment = await prisma.punishment.findFirst({
where: {
userId: Number(session.user?.id),
returned: false,
},
include: {
violatingMiis: {
include: {
mii: {
select: {
name: true,
},
},
},
},
},
});
if (!activePunishment) return rateLimit.sendResponse({ error: "You have no active punishments!" }, 404);
if (activePunishment.type === "PERM_EXILE") return rateLimit.sendResponse({ error: "Your punishment is permanent" }, 403);
if (activePunishment.type === "TEMP_EXILE" && activePunishment.expiresAt! > new Date())
return rateLimit.sendResponse({ error: "Your punishment has not expired yet." }, 403);
await prisma.punishment.update({
where: {
id: activePunishment.id,
},
data: {
returned: true,
},
});
return rateLimit.sendResponse({ success: true });
}

View file

@ -0,0 +1,326 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
import qrcode from "qrcode-generator";
import { profanity } from "@2toad/profanity";
import { MiiGender, MiiMakeup, MiiPlatform } from "@prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage, validateImage } from "@/lib/images";
import Mii from "../../../../../shared/src/mii.js/mii";
import { convertQrCode, minifyInstructions, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared";
import { SwitchMiiInstructions } from "@tomodachi-share/shared";
import { settings } from "@/lib/settings";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
const submitSchema = z
.object({
platform: z.enum(MiiPlatform).default("THREE_DS"),
name: nameSchema,
tags: tagsSchema,
description: z.string().trim().max(512).optional(),
// Switch
gender: z.enum(MiiGender).default("MALE"),
makeup: z.enum(MiiMakeup).default("PARTIAL"),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
youtubeId: z
.string()
.trim()
.transform((val) => (val === "" ? null : val))
.refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID")
.optional(),
instructions: switchMiiInstructionsSchema,
// QR code
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",
})
.nullish(),
// Custom images
image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(),
image3: z.union([z.instanceof(File), z.any()]).optional(),
})
// This refine function is probably useless
.refine(
(data) => {
// If platform is Switch, gender, miiPortraitImage, and miiFeaturesImage must be present
if (data.platform === "SWITCH") {
return data.gender !== undefined && data.miiPortraitImage !== undefined && data.miiFeaturesImage !== undefined;
}
return true;
},
{
message: "Gender, Mii portrait & features image are required for Switch platform",
path: ["gender", "miiPortraitImage", "miiFeaturesImage"],
},
);
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
if (!settings.canSubmit) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503);
// Parse tags and QR code as JSON
const formData = await request.formData();
let rawTags: string[];
let rawQrBytesRaw: string[]; // raw raw
try {
rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
} catch (error) {
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
}
// Minify instructions to save space and improve user experience
let minifiedInstructions: Partial<SwitchMiiInstructions> | undefined;
if (formData.get("platform") === "SWITCH")
minifiedInstructions = minifyInstructions(JSON.parse((formData.get("instructions") as string) ?? "{}") as SwitchMiiInstructions);
// Parse and check all submission info
const parsed = submitSchema.safeParse({
platform: formData.get("platform"),
name: formData.get("name"),
tags: rawTags,
description: formData.get("description"),
gender: formData.get("gender") ?? undefined, // ZOD MOMENT
makeup: formData.get("makeup") ?? undefined,
miiPortraitImage: formData.get("miiPortraitImage"),
miiFeaturesImage: formData.get("miiFeaturesImage"),
youtubeId: formData.get("youtubeId"),
instructions: minifiedInstructions,
qrBytesRaw: rawQrBytesRaw,
image1: formData.get("image1"),
image2: formData.get("image2"),
image3: formData.get("image3"),
});
if (!parsed.success) {
const firstIssue = parsed.error.issues[0];
const path = firstIssue.path.length ? firstIssue.path.join(".") : "root";
const error = `${path}: ${firstIssue.message}`;
return rateLimit.sendResponse({ error }, 400);
}
const {
platform,
name: uncensoredName,
tags: uncensoredTags,
description: uncensoredDescription,
qrBytesRaw,
gender,
makeup,
miiPortraitImage,
miiFeaturesImage,
youtubeId,
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);
// Validate image files
const customImages: File[] = [];
for (const img of [image1, image2, image3]) {
if (!img) continue;
const validation = await validateImage(img);
if (validation.valid) {
customImages.push(img);
} else {
return rateLimit.sendResponse({ error: `Failed to verify custom image: ${validation.error}` }, validation.status ?? 400);
}
}
// Check Mii portrait & features image (Switch)
if (platform === "SWITCH") {
const portraitValidation = await validateImage(miiPortraitImage);
const featuresValidation = await validateImage(miiFeaturesImage);
if (!portraitValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify portrait: ${portraitValidation.error}` }, portraitValidation.status ?? 400);
if (!featuresValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify features: ${featuresValidation.error}` }, featuresValidation.status ?? 400);
}
const qrBytes = new Uint8Array(qrBytesRaw ?? []);
// Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii } | undefined;
if (platform === "THREE_DS") {
try {
conversion = convertQrCode(qrBytes);
} catch (error) {
return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
}
}
// Create Mii in database
const miiRecord = await prisma.mii.create({
data: {
userId: Number(session.user?.id),
platform,
name,
tags,
description,
gender: gender ?? "MALE",
in_queue: settings.queueEnabled,
// Automatically detect certain information if on 3DS
...(platform === "THREE_DS"
? conversion && {
firstName: conversion.tomodachiLifeMii.firstName,
lastName: conversion.tomodachiLifeMii.lastName,
gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE,
islandName: conversion.tomodachiLifeMii.islandName,
allowedCopying: conversion.mii.allowCopying,
}
: {
youtubeId,
instructions: minifiedInstructions,
makeup: makeup ?? "PARTIAL",
}),
},
});
// Ensure directories exist
const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString());
await fs.mkdir(miiUploadsDirectory, { recursive: true });
try {
let portraitBuffer: Buffer | undefined;
// Download the image of the Mii (3DS)
if (platform === "THREE_DS") {
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
const studioResponse = await fetch(studioUrl!);
if (!studioResponse.ok) {
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
}
portraitBuffer = Buffer.from(await studioResponse.arrayBuffer());
} else if (platform === "SWITCH") {
portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
// Save features image
const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer());
const pngBuffer = await sharp(featuresBuffer)
.resize({
height: 800,
fit: "inside",
withoutEnlargement: true,
})
.png({ quality: 85 })
.toBuffer();
const fileLocation = path.join(miiUploadsDirectory, "features.png");
await fs.writeFile(fileLocation, pngBuffer);
}
// Save portrait image
if (!portraitBuffer) throw Error("Mii portrait buffer not initialised");
const pngBuffer = await sharp(portraitBuffer)
.resize({
height: 500,
fit: "inside",
withoutEnlargement: true,
})
.png({ quality: 85 })
.toBuffer();
const fileLocation = path.join(miiUploadsDirectory, "mii.png");
await fs.writeFile(fileLocation, pngBuffer);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Failed to download/store Mii portrait/features:", error);
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait/features" }, 500);
}
try {
await generateMetadataImage(miiRecord, session.user?.name!);
} catch (error) {
console.error("Failed to generate metadata image:", error);
}
if (platform === "THREE_DS") {
try {
// Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte");
generatedCode.make();
// Store QR code
const codeDataUrl = generatedCode.createDataURL();
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
const codeBuffer = Buffer.from(codeBase64, "base64");
// Compress and store
const codePngBuffer = await sharp(codeBuffer).png({ quality: 85 }).toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.png");
await fs.writeFile(codeFileLocation, codePngBuffer);
} catch (error) {
// Clean up if something went wrong
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);
}
}
// Compress and store user images
try {
await Promise.all(
customImages.map(async (image, index) => {
const buffer = Buffer.from(await image.arrayBuffer());
const pngBuffer = await sharp(buffer).resize({ height: 800, fit: "inside", withoutEnlargement: true }).png({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, `image${index}.png`);
await fs.writeFile(fileLocation, pngBuffer);
}),
);
// Update database to tell it how many images exist
await prisma.mii.update({
where: {
id: miiRecord.id,
},
data: {
imageCount: customImages.length,
},
});
} catch (error) {
console.error("Error storing user images:", error);
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
}
return rateLimit.sendResponse({ success: true, id: miiRecord.id });
}

View file

@ -0,0 +1,50 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import EditForm from "@/components/submit-form/edit-form";
interface Props {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const mii = await prisma.mii.findUnique({
where: {
id: Number(id),
},
});
return {
title: `${mii?.name} - TomodachiShare`,
description: `Edit the name, tags, and images of '${mii?.name}'`,
robots: {
index: false,
follow: false,
},
};
}
export default async function MiiPage({ params }: Props) {
const { id } = await params;
const session = await auth();
const mii = await prisma.mii.findUnique({
where: {
id: Number(id),
},
include: {
_count: {
select: { likedBy: true }, // Get total like count
},
},
});
// Check ownership
if (!mii || (Number(session?.user?.id) !== mii.userId && Number(session?.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) redirect("/404");
return <EditForm mii={mii} likes={mii._count.likedBy} />;
}

View file

@ -0,0 +1,115 @@
import { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import fs from "fs/promises";
import path from "path";
import { z } from "zod";
import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage } from "@/lib/images";
import { prisma } from "@/lib/prisma";
const searchParamsSchema = z.object({
type: z
.enum(["mii", "qr-code", "features", "image0", "image1", "image2", "metadata"], {
message: "Image type must be either 'mii', 'qr-code', 'features', 'image[number from 0 to 2]' or 'metadata'",
})
.default("mii"),
});
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 200, "/mii/image");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
const searchParamsParsed = searchParamsSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.issues[0].message }, 400);
const { type: imageType } = searchParamsParsed.data;
const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}.png`);
let buffer: Buffer | undefined;
// Only find Mii if image type is 'metadata'
let mii: Prisma.MiiGetPayload<{
include: {
user: {
select: {
name: true;
};
};
};
}> | null = null;
if (imageType === "metadata") {
mii = await prisma.mii.findUnique({
where: {
id: miiId,
},
include: {
user: {
select: {
name: true,
},
},
},
});
if (!mii) {
return rateLimit.sendResponse({ error: "Mii not found" }, 404);
}
}
try {
// Try to read file
buffer = await fs.readFile(filePath);
} catch {
// If the readFile() fails, that probably means it doesn't exist
if (imageType === "metadata" && mii) {
// Metadata images were added after 1274 Miis were submitted, so we generate it on-the-fly
console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`);
const { buffer: metadataBuffer, error, status } = await generateMetadataImage(mii, mii.user.name!);
if (error) {
return rateLimit.sendResponse({ error }, status);
}
buffer = metadataBuffer;
} else {
return rateLimit.sendResponse({ error: "Image not found" }, 404);
}
}
if (!buffer) return rateLimit.sendResponse({ error: "Image not found" }, 404);
// Set the file name for the metadata image in the response for SEO
if (mii && imageType === "metadata") {
const slugify = (str: string) =>
str
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-") // replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, "");
const name = slugify(mii.name);
const tags = mii.tags.map(slugify).join("-");
const filename = `${name}-mii-${tags}.png`;
return rateLimit.sendResponse(buffer, 200, {
"Content-Type": "image/png",
"Content-Disposition": `inline; filename="${filename}"`,
"Cache-Control": "public, max-age=31536000",
});
}
return rateLimit.sendResponse(buffer, 200, {
"Content-Type": "image/png",
"X-Robots-Tag": "noindex, noimageindex, nofollow",
"Cache-Control": "public, max-age=60, stale-while-revalidate=30",
});
}

View file

@ -0,0 +1,122 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import dayjs from "dayjs";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// import ReturnToIsland from "@/components/admin/return-to-island";
export const metadata: Metadata = {
title: "Exiled - TomodachiShare",
description: "You have been exiled from the TomodachiShare island...",
robots: {
index: false,
follow: false,
},
};
export default async function ExiledPage() {
const session = await auth();
if (!session?.user) redirect("/");
const activePunishment = await prisma.punishment.findFirst({
where: {
userId: Number(session?.user.id),
returned: false,
},
include: {
violatingMiis: {
include: {
mii: {
select: {
name: true,
},
},
},
},
},
});
if (!activePunishment) redirect("/");
const expiresAt = dayjs(activePunishment.expiresAt);
const createdAt = dayjs(activePunishment.createdAt);
const hasExpired = activePunishment.type === "TEMP_EXILE" && activePunishment.expiresAt! > new Date();
const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true));
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xl w-full flex flex-col">
<h2 className="text-4xl font-black mb-2">
{activePunishment.type === "PERM_EXILE"
? "Exiled permanently"
: activePunishment.type === "TEMP_EXILE"
? `Exiled for ${duration} ${duration === 1 ? "day" : "days"}`
: "Warning"}
</h2>
<p>
You have been exiled from the TomodachiShare island because you violated the{" "}
<Link href={"/terms-of-service"} className="text-blue-500">
Terms of Service
</Link>
.
</p>
<p className="mt-3">
<span className="font-bold">Reviewed:</span> {activePunishment.createdAt.toLocaleDateString("en-GB")} at{" "}
{activePunishment.createdAt.toLocaleString("en-GB")}
</p>
<p className="mt-1">
<span className="font-bold">Note:</span> {activePunishment.notes}
</p>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4">
<hr className="grow border-zinc-300" />
<span>Violating Items</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col gap-2 p-4">
{activePunishment.reasons.map((index, reason) => (
<div key={index} className="bg-orange-100 rounded-xl border-2 border-orange-400 p-4">
<p>
<span className="font-bold">Reason:</span> {reason}
</p>
</div>
))}
{activePunishment.violatingMiis.map((mii) => (
<div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p>
<p className="text-sm">
<span className="font-bold">Reason:</span> {mii.reason}
</p>
</div>
</div>
))}
</div>
<hr className="border-zinc-300 mt-2 mb-4" />
{activePunishment.type !== "PERM_EXILE" ? (
<>
<p className="mb-2">Once your punishment ends, you can return by checking the box below.</p>
{/* <ReturnToIsland hasExpired={hasExpired} /> */}
</>
) : (
<>
<p>Your punishment is permanent, therefore you cannot return.</p>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,72 @@
import { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Icon } from "@iconify/react";
export const metadata: Metadata = {
title: "Leaving TomodachiShare",
description: "Warning: You are leaving TomodachiShare, proceed with caution",
};
interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function LinkOutPage({ searchParams }: Props) {
const url = (await searchParams).url;
if (!url || Array.isArray(url)) redirect("/");
let parsed: URL;
try {
parsed = new URL(url);
} catch {
redirect("/"); // redirect if URL is invalid
}
// Next.js doesn't allow attacks like these but you can never be too safe
if (!["http:", "https:"].includes(parsed.protocol)) redirect("/");
const isSafe = Array.from(SAFE_LINKS).some((domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`));
if (isSafe) redirect(url);
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg py-8 px-6 max-w-md w-full text-center flex flex-col items-center">
<h2 className="text-3xl font-black flex items-center gap-2 mb-1">
<Icon icon="mingcute:alert-fill" className="text-5xl" />
Warning
</h2>
<p>You're attempting to leave TomodachiShare island! The destination website is potentially dangerous.</p>
<div className="bg-zinc-100 border border-zinc-300 rounded-md p-2 break-all w-full mt-4">
<code className="font-mono text-sm">{url}</code>
</div>
<div className="flex justify-center gap-2">
<Link href="/" className="pill button gap-2 mt-8 w-fit self-center bg-zinc-100! border-zinc-300! hover:bg-zinc-300!">
<Icon icon="ic:round-home" fontSize={24} />
Travel Back
</Link>
<Link href={url} target="_blank" rel="noopener noreferrer" className="pill button gap-2 mt-8 w-fit self-center">
<Icon icon="ic:round-open-in-new" fontSize={21} />
Continue
</Link>
</div>
</div>
</div>
);
}
const SAFE_LINKS = new Set([
"tomodachishare.com",
"trafficlunar.net",
"youtube.com",
"youtu.be",
"twitter.com",
"x.com",
"reddit.com",
"tiktok.com",
"tumblr.com",
"instagram.com",
"wikipedia.org",
]);

9
backend/src/app/page.tsx Normal file
View file

@ -0,0 +1,9 @@
export default function IndexPage() {
return (
<html>
<body>
<p>TomodachiShare API</p>
</body>
</html>
);
}

View file

@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 16, "/profile/picture");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const userId = parsed.data;
const filePath = path.join(process.cwd(), "uploads", "user", `${userId}.png`);
try {
const buffer = await fs.readFile(filePath);
return new NextResponse(new Uint8Array(buffer)); // convert to Uint8Array due to weird types issue
} catch {
return rateLimit.sendResponse({ error: "Image not found" }, 404);
}
}

View file

@ -0,0 +1,20 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function RandomPage() {
const count = await prisma.mii.count();
if (count === 0) redirect("/");
const randomIndex = Math.floor(Math.random() * count);
const randomMii = await prisma.mii.findFirst({
where: { in_queue: false, quarantined: false },
skip: randomIndex,
take: 1,
select: { id: true },
});
if (!randomMii) redirect(process.env.NEXT_PUBLIC_FRONTEND_URL || "http://localhost:4321");
redirect(`${process.env.NEXT_PUBLIC_FRONTEND_URL}/mii/${randomMii.id}`);
}

View file

@ -0,0 +1,47 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import ReportMiiForm from "@/components/report/mii-form";
interface Props {
params: Promise<{ id: string }>;
}
export const metadata: Metadata = {
title: "Report Mii - TomodachiShare",
description: "Report a Mii on TomodachiShare",
robots: {
index: false,
follow: false,
},
};
export default async function ReportMiiPage({ params }: Props) {
const session = await auth();
const { id } = await params;
const mii = await prisma.mii.findUnique({
where: {
id: Number(id),
},
include: {
_count: {
select: {
likedBy: true,
},
},
},
});
if (!session) redirect("/login");
if (!mii) redirect("/404");
return (
<div className="flex justify-center w-full">
<ReportMiiForm mii={mii} likes={mii._count.likedBy} />
</div>
);
}

View file

@ -0,0 +1,40 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import ReportUserForm from "@/components/report/user-form";
interface Props {
params: Promise<{ id: string }>;
}
export const metadata: Metadata = {
title: "Report User - TomodachiShare",
description: "Report a user on TomodachiShare",
robots: {
index: false,
follow: false,
},
};
export default async function ReportUserPage({ params }: Props) {
const session = await auth();
const { id } = await params;
const user = await prisma.user.findUnique({
where: {
id: Number(id),
},
});
if (!session) redirect("/login");
if (!user) redirect("/404");
return (
<div className="flex justify-center w-full">
<ReportUserForm user={user} />
</div>
);
}

12
backend/src/app/robots.ts Normal file
View file

@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/*?*page=", "/profile*?*tags=", "/edit/*", "/profile/settings", "/random", "/submit", "/report/mii/*", "/report/user/*", "/admin"],
},
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
};
}

View file

@ -0,0 +1,80 @@
import { prisma } from "@/lib/prisma";
import type { MetadataRoute } from "next";
type SitemapRoute = MetadataRoute.Sitemap[0];
export const revalidate = 43200; // update every 12 hours
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
if (!baseUrl) {
console.error("NEXT_PUBLIC_BASE_URL environment variable missing");
return [];
}
const miis = await prisma.mii.findMany({
select: {
id: true,
createdAt: true,
},
});
const users = await prisma.user.findMany({
select: {
id: true,
updatedAt: true,
},
});
const dynamicRoutes: MetadataRoute.Sitemap = [
...miis.map(
(mii) =>
({
url: `${baseUrl}/mii/${mii.id}`,
lastModified: mii.createdAt,
changeFrequency: "weekly",
priority: 0.7,
images: [`${baseUrl}/mii/${mii.id}/image?type=metadata`],
}) as SitemapRoute,
),
...users.map(
(user) =>
({
url: `${baseUrl}/profile/${user.id}`,
lastModified: user.updatedAt,
changeFrequency: "weekly",
priority: 0.2,
}) as SitemapRoute,
),
];
const lastModified = new Date();
return [
{
url: baseUrl,
lastModified,
changeFrequency: "always",
priority: 1,
},
{
url: `${baseUrl}/login`,
lastModified,
changeFrequency: "monthly",
priority: 0.6,
},
{
url: `${baseUrl}/privacy`,
lastModified,
changeFrequency: "yearly",
priority: 0.4,
},
{
url: `${baseUrl}/terms-of-service`,
lastModified,
changeFrequency: "yearly",
priority: 0.4,
},
...dynamicRoutes,
];
}

View file

@ -0,0 +1,79 @@
import { useState } from "react";
import { type Mii, ReportReason } from "@tomodachi-share/backend";
import ReasonSelector from "./reason-selector";
import SubmitButton from "../submit-button";
interface Props {
mii: Mii;
likes: number;
}
export default function ReportMiiForm({ mii, likes }: Props) {
const [reason, setReason] = useState<ReportReason>();
const [notes, setNotes] = useState<string>();
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch(`/api/report`, {
method: "POST",
body: JSON.stringify({ id: mii.id, type: "mii", reason: reason?.toLowerCase(), notes }),
});
const { error } = await response.json();
if (!response.ok) {
setError(error);
return;
}
// redirect(`/`);
window.location.href = "https://tomodachishare.com";
};
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 w-full max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Report a Mii</h2>
<p className="text-sm text-zinc-500">If you encounter a rule-breaking Mii, please report it here</p>
</div>
<hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<img src={`/mii/${mii.id}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.name}</p>
{/* <LikeButton likes={likes} isLiked={true} disabled /> */}
</div>
</div>
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="reason" className="font-semibold">
Reason
</label>
<ReasonSelector reason={reason} setReason={setReason} />
</div>
<div className="w-full grid grid-cols-3">
<label htmlFor="reason-note" className="font-semibold">
Reason notes
</label>
<textarea
rows={3}
maxLength={256}
placeholder="Type notes here for the report..."
className="pill input rounded-xl! resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<hr className="border-zinc-300" />
<div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
import { Icon } from "@iconify/react";
import { ReportReason } from "@prisma/client";
import { useSelect } from "downshift";
interface Props {
reason: ReportReason | undefined;
setReason: React.Dispatch<React.SetStateAction<ReportReason | undefined>>;
}
const reasonMap: Record<ReportReason, string> = {
INAPPROPRIATE: "Inappropriate content",
SPAM: "Spam",
BAD_QUALITY: "Bad quality",
OTHER: "Other...",
};
const reasonOptions = Object.entries(reasonMap).map(([value, label]) => ({
value: value as ReportReason,
label,
}));
export default function ReasonSelector({ reason, setReason }: Props) {
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
items: reasonOptions,
selectedItem: reason ? reasonOptions.find((option) => option.value === reason) : null,
itemToString: (item) => (item ? item.label : ""),
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem) {
setReason(selectedItem.value);
}
},
});
return (
<div className="relative w-full col-span-2">
{/* Toggle button to open the dropdown */}
<button type="button" {...getToggleButtonProps()} aria-label="Report reason dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
{selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>
{/* Dropdown menu */}
<ul
{...getMenuProps()}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isOpen ? "block" : "hidden"
}`}
>
{isOpen &&
reasonOptions.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { useState } from "react";
import ReasonSelector from "./reason-selector";
import SubmitButton from "../submit-button";
import { ReportReason } from "@prisma/client";
import { User } from "next-auth";
interface Props {
user: User;
}
export default function ReportUserForm({ user }: Props) {
const [reason, setReason] = useState<ReportReason>();
const [notes, setNotes] = useState<string>();
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch(`/api/report`, {
method: "POST",
body: JSON.stringify({ id: user.id, type: "user", reason: reason?.toLowerCase(), notes }),
});
const { error } = await response.json();
if (!response.ok) {
setError(error);
return;
}
window.location.href = "https://tomodachishare.com";
};
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 w-full max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Report a User</h2>
<p className="text-sm text-zinc-500">If you encounter a user causing issues, please report them here</p>
</div>
<hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex p-4 gap-4">
<image src={user.image ?? "/guest.png"} width={96} height={96} className="aspect-square rounded-full border-2 border-orange-400" />
<p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
</div>
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="reason" className="font-semibold">
Reason
</label>
<ReasonSelector reason={reason} setReason={setReason} />
</div>
<div className="w-full grid grid-cols-3">
<label htmlFor="reason-note" className="font-semibold">
Reason notes
</label>
<textarea
rows={3}
maxLength={256}
placeholder="Type notes here for the report..."
className="pill input rounded-xl! resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<hr className="border-zinc-300" />
<div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { useState } from "react";
import { Icon } from "@iconify/react";
interface Props {
onClick: () => void | Promise<void>;
disabled?: boolean;
text?: string;
className?: string;
}
export default function SubmitButton({ onClick, disabled = false, text = "Submit", className }: Props) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async (event: React.FormEvent) => {
event.preventDefault();
setIsLoading(true);
try {
await onClick();
} finally {
setIsLoading(false);
}
};
return (
<button type="submit" aria-label={text} onClick={handleClick} disabled={disabled} className={`pill button w-min ${className}`}>
{text}
{isLoading && <Icon icon="svg-spinners:180-ring-with-bg" className="ml-2" />}
</button>
);
}

49
backend/src/lib/auth.ts Normal file
View file

@ -0,0 +1,49 @@
import NextAuth from "next-auth";
import Discord from "next-auth/providers/discord";
import Github from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [Discord, Github({ issuer: "https://github.com/login/oauth" }), Google],
trustHost: true,
cookies: {
sessionToken: {
name: process.env.NODE_ENV === "production" ? "__Secure-next-auth.session-token" : "next-auth.session-token",
options: {
httpOnly: true,
sameSite: "none",
path: "/",
secure: true,
},
},
},
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60,
},
callbacks: {
async signIn({ user }) {
const blacklist = process.env.BLACKLISTED_EMAILS ? process.env.BLACKLISTED_EMAILS.split(",").map((item) => item.trim().toLowerCase()) : [];
const email = user?.email?.toLowerCase();
if (!email) return false;
if (blacklist?.some((blocked) => email.endsWith(blocked))) return false;
return true;
},
async session({ session, user }) {
if (user) {
session.user.id = user.id;
session.user.email = user.email;
}
return session;
},
async redirect({ url, baseUrl }) {
return process.env.FRONTEND_URL ?? "http://localhost:4321";
},
},
});

223
backend/src/lib/images.tsx Normal file
View file

@ -0,0 +1,223 @@
// This file's extension is .tsx because JSX is used for satori to generate images
// Warnings below are disabled since satori is not Next.JS and is turned into an image anyways
/* eslint-disable jsx-a11y/alt-text */
/* eslint-disable @next/next/no-img-element */
import type { ReactNode } from "react";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
import { fileTypeFromBuffer } from "file-type";
import satori, { Font } from "satori";
import { Mii } from "@prisma/client";
const MIN_IMAGE_DIMENSIONS = [128, 128];
const MAX_IMAGE_DIMENSIONS = [8000, 8000];
const MAX_IMAGE_SIZE = 8 * 1024 * 1024; // 8 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
//#region Image validation
export async function validateImage(file: File): Promise<{ valid: boolean; error?: string; status?: number }> {
if (!file || file.size == 0) return { valid: false, error: "Empty image file" };
if (file.size > MAX_IMAGE_SIZE) return { valid: false, error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB` };
try {
const buffer = Buffer.from(await file.arrayBuffer());
// Check mime type
const fileType = await fileTypeFromBuffer(buffer);
if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime))
return { valid: false, error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed" };
let metadata: sharp.Metadata;
try {
metadata = await sharp(buffer).metadata();
} catch {
return { valid: false, error: "Invalid or corrupted image file" };
}
// Check image dimensions
if (
!metadata.width ||
!metadata.height ||
metadata.width < MIN_IMAGE_DIMENSIONS[0] ||
metadata.width > MAX_IMAGE_DIMENSIONS[0] ||
metadata.height < MIN_IMAGE_DIMENSIONS[1] ||
metadata.height > MAX_IMAGE_DIMENSIONS[1]
) {
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 8000x8000" };
}
return { valid: true };
} catch (error) {
console.error("Error validating image:", error);
return { valid: false, error: "Failed to process image file", status: 500 };
}
}
//#endregion
//#region Generating 'metadata' image type
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
const fontCache: Record<string, Font | null> = {
regular: null,
medium: null,
semiBold: null,
bold: null,
extraBold: null,
black: null,
};
// Load fonts only once and cache them
const loadFonts = async (): Promise<Font[]> => {
const weights = [
["regular", 400],
["medium", 500],
["semiBold", 600],
["bold", 700],
["extraBold", 800],
["black", 900],
] as const;
return Promise.all(
weights.map(async ([weight, value]) => {
if (!fontCache[weight]) {
const filePath = path.join(process.cwd(), `public/fonts/lexend-${weight}.ttf`);
const data = await fs.readFile(filePath);
fontCache[weight] = {
name: "Lexend",
data,
weight: value,
};
}
return fontCache[weight]!;
}),
);
};
export async function generateMetadataImage(mii: Mii, author: string): Promise<{ buffer?: Buffer; error?: string; status?: number }> {
const miiUploadsDirectory = path.join(uploadsDirectory, mii.id.toString());
// Load assets concurrently
const [miiImage, qrCodeImage, fonts] = await Promise.all([
// Read and convert the images to data URI
fs.readFile(path.join(miiUploadsDirectory, "mii.png")).then((buffer) =>
sharp(buffer)
// extend to fix shadow bug on landscape pictures
.extend({
left: 16,
right: 16,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
),
mii.platform === "THREE_DS"
? fs.readFile(path.join(miiUploadsDirectory, "qr-code.png")).then((buffer) =>
sharp(buffer)
.toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
)
: Promise.resolve(null),
loadFonts(),
]);
const jsx: ReactNode = (
<div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col">
<div tw="flex w-full">
{/* Mii portrait */}
<div
tw={`h-62 rounded-xl flex justify-center items-center mr-2 ${mii.platform === "THREE_DS" ? "w-80" : "w-100"}`}
style={{
backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);",
}}
>
<img
src={miiImage}
height={248}
tw="w-full h-full"
style={{
objectFit: "contain",
filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)",
}}
/>
</div>
{/* QR code */}
{mii.platform === "THREE_DS" ? (
<div tw="w-60 bg-amber-200 rounded-xl flex justify-center items-center">
<img src={qrCodeImage!} width={190} height={190} tw="border-2 border-amber-300 rounded-lg" />
</div>
) : (
<div tw="w-40 bg-amber-200 rounded-xl flex flex-col justify-center items-center p-6">
<span tw="text-amber-900 font-extrabold text-xl text-center leading-tight">Switch Guide</span>
<p tw="text-amber-800 text-sm text-center mt-1.5">To fully create the Mii, visit the site for instructions.</p>
<div tw="mt-auto bg-amber-600 rounded-lg w-full py-2 flex justify-center">
<span tw="text-white font-semibold">View Steps</span>
</div>
</div>
)}
</div>
<div tw="flex flex-col w-full h-30 relative">
{/* Mii name */}
<span tw="text-4xl font-extrabold text-amber-700 mt-2" style={{ display: "block", lineClamp: 1, wordBreak: "break-word" }}>
{mii.name}
</span>
{/* Tags */}
<div id="tags" tw="relative flex mt-1 w-full overflow-hidden">
<div tw="flex">
{mii.tags.map((tag) => (
<span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm shrink-0">
{tag}
</span>
))}
</div>
<div tw="absolute inset-0" style={{ position: "absolute", backgroundImage: "linear-gradient(to right, #fffbeb00 70%, #fffbeb);" }}></div>
</div>
{/* Author */}
<div tw="flex mt-2 text-sm w-1/2">
By{" "}
<span tw="ml-1.5 font-semibold overflow-hidden" style={{ textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{author}
</span>
</div>
{/* Watermark */}
<div tw="absolute bottom-0 right-0 flex items-center">
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={32} />
{/* I tried using text-orange-400 but it wasn't correct..? */}
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
TomodachiShare
</span>
</div>
</div>
</div>
);
const svg = await satori(jsx, {
width: 600,
height: 400,
fonts,
});
// Convert .svg to .png
const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
// Store the file
try {
const fileLocation = path.join(miiUploadsDirectory, "metadata.png");
await fs.writeFile(fileLocation, buffer);
} catch (error) {
console.error("Error storing 'metadata' image type", error);
return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
}
return { buffer };
}
//#endregion

View file

@ -0,0 +1,7 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View file

@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient, RedisClientType } from "redis";
import { auth } from "./auth";
const WINDOW_SIZE = 60;
let client: RedisClientType | null = null;
interface RateLimitData {
success: boolean;
limit: number;
remaining: number;
expires: number;
}
async function getRedisClient() {
if (!client) {
client = createClient({
url: process.env.REDIS_URL,
});
client.on("error", (error) => {
console.error("Redis client error", error);
});
await client.connect();
}
return client;
}
// Fixed window implementation
export class RateLimit {
private request: NextRequest;
private maxRequests: number;
private pathname: string; // instead of using the request's pathname, use this custom one to group all routes together
private data: RateLimitData;
constructor(request: NextRequest, maxRequests: number, pathname?: string) {
this.request = request;
this.maxRequests = maxRequests;
this.pathname = pathname ? pathname : this.request.nextUrl.pathname;
this.data = {
success: true,
limit: maxRequests,
remaining: maxRequests,
expires: Date.now(),
};
}
// Check and update rate limit
async check(identifier: string): Promise<RateLimitData> {
const key = `ratelimit:${this.pathname}:${identifier}`;
const now = Date.now();
const seconds = Math.floor(now / 1000);
const currentWindow = Math.floor(seconds / WINDOW_SIZE) * WINDOW_SIZE;
const expireAt = currentWindow + WINDOW_SIZE;
try {
const client = await getRedisClient();
// Execute a Redis transaction and get the count
const [result] = await client.multi().incr(key).expireAt(key, expireAt).exec();
if (!result) {
throw new Error("Redis transaction failed");
}
const count = result as unknown as number;
const success = count <= this.maxRequests;
const remaining = Math.max(0, this.maxRequests - count);
return { success, limit: this.maxRequests, remaining, expires: expireAt };
} catch (error) {
console.error("Rate limit check failed", error);
return {
success: false,
limit: this.maxRequests,
remaining: this.maxRequests,
expires: expireAt,
};
}
}
// Attach rate limit headers to a response
sendResponse(body: object | Buffer, status: number = 200, headers?: HeadersInit): NextResponse<object | unknown> {
let response: NextResponse;
if (Buffer.isBuffer(body)) {
response = new NextResponse(new Uint8Array(body), { status, headers }); // convert to Uint8Array due to weird types issue
} else {
response = NextResponse.json(body, { status, headers });
}
response.headers.set("X-RateLimit-Limit", this.data.limit.toString());
response.headers.set("X-RateLimit-Remaining", this.data.remaining.toString());
response.headers.set("X-RateLimit-Expires", this.data.expires.toString());
return response;
}
// Handle both functions above and identifier in one
async handle(): Promise<NextResponse<object | unknown> | undefined> {
const session = await auth();
const ip = this.request.headers.get("CF-Connecting-IP") || this.request.headers.get("X-Forwarded-For")?.split(",")[0];
const identifier = (session ? session.user?.id : ip) ?? "anonymous";
this.data = await this.check(identifier);
if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429);
return;
}
}

View file

@ -0,0 +1,4 @@
export const settings = {
canSubmit: true,
queueEnabled: true,
};

18
backend/src/lib/utils.ts Normal file
View file

@ -0,0 +1,18 @@
export function deepMerge<T>(target: T, source: Partial<T>): T {
const output = structuredClone(target);
if (typeof source !== "object" || source === null) return output;
for (const key in source) {
const sourceValue = source[key];
const targetValue = (output as any)[key];
if (typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue)) {
(output as any)[key] = deepMerge(targetValue, sourceValue);
} else {
(output as any)[key] = sourceValue;
}
}
return output;
}

2
backend/src/types.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
export type { User, Mii, Punishment, Prisma } from "@prisma/client";
export { MiiPlatform, MiiGender, MiiMakeup, ReportReason } from "@prisma/client";

42
backend/tsconfig.json Normal file
View file

@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"sjcl-with-all": ["./node_modules/@types/sjcl"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"../shared/src/constants.ts",
"../frontend/src/lib/abbreviation.ts",
"../shared/src/qr-codes.ts",
"../shared/src/three-ds-tomodachi-life-mii.ts",
"../shared/src/types.d.ts",
"../shared/src/switch.ts",
"../frontend/src/components/provider.tsx",
"../shared/src/schemas.ts"
],
"exclude": ["node_modules"]
}