mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-27 22:24:14 +00:00
feat: astro test
This commit is contained in:
parent
df6e31ba89
commit
84144c383c
262 changed files with 18993 additions and 2655 deletions
11
backend/.dockerignore
Normal file
11
backend/.dockerignore
Normal 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
30
backend/.env.example
Normal 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
51
backend/Dockerfile
Normal 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
14
backend/eslint.config.mjs
Normal 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
22
backend/next.config.ts
Normal 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
63
backend/package.json
Normal 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
5082
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
5
backend/postcss.config.mjs
Normal file
5
backend/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
90
backend/prisma/migrations/20250423164356_init/migration.sql
Normal file
90
backend/prisma/migrations/20250423164356_init/migration.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "MiiGender" AS ENUM ('MALE', 'FEMALE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "miis" ADD COLUMN "gender" "MiiGender";
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "miis" ADD COLUMN "description" VARCHAR(256);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "imageUpdatedAt" TIMESTAMP(3);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "punishments" ADD COLUMN "returned" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "description" VARCHAR(256);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "MiiGender" ADD VALUE 'NONBINARY';
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "miis" ADD COLUMN "instructions" JSONB;
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "ReportReason" ADD VALUE 'BAD_QUALITY';
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "MiiMakeup" AS ENUM ('FULL', 'PARTIAL', 'NONE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "miis" ADD COLUMN "makeup" "MiiMakeup";
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "miis" ADD COLUMN "quarantined" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -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");
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "miis" ADD COLUMN "in_queue" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "miis" ADD COLUMN "youtubeId" TEXT;
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
211
backend/prisma/schema.prisma
Normal file
211
backend/prisma/schema.prisma
Normal 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
|
||||
}
|
||||
1
backend/public/fonts/README.md
Normal file
1
backend/public/fonts/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
These fonts are used for generating the 'metadata' image type for Miis (the images you should see in search engines!)
|
||||
BIN
backend/public/fonts/lexend-black.ttf
Normal file
BIN
backend/public/fonts/lexend-black.ttf
Normal file
Binary file not shown.
BIN
backend/public/fonts/lexend-bold.ttf
Normal file
BIN
backend/public/fonts/lexend-bold.ttf
Normal file
Binary file not shown.
BIN
backend/public/fonts/lexend-extraBold.ttf
Normal file
BIN
backend/public/fonts/lexend-extraBold.ttf
Normal file
Binary file not shown.
BIN
backend/public/fonts/lexend-medium.ttf
Normal file
BIN
backend/public/fonts/lexend-medium.ttf
Normal file
Binary file not shown.
BIN
backend/public/fonts/lexend-regular.ttf
Normal file
BIN
backend/public/fonts/lexend-regular.ttf
Normal file
Binary file not shown.
BIN
backend/public/fonts/lexend-semiBold.ttf
Normal file
BIN
backend/public/fonts/lexend-semiBold.ttf
Normal file
Binary file not shown.
1
backend/public/logo.svg
Normal file
1
backend/public/logo.svg
Normal 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 |
29
backend/src/app/api/admin/accept-mii/route.ts
Normal file
29
backend/src/app/api/admin/accept-mii/route.ts
Normal 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 });
|
||||
}
|
||||
30
backend/src/app/api/admin/banner/route.ts
Normal file
30
backend/src/app/api/admin/banner/route.ts
Normal 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 });
|
||||
}
|
||||
22
backend/src/app/api/admin/can-submit/route.ts
Normal file
22
backend/src/app/api/admin/can-submit/route.ts
Normal 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 });
|
||||
}
|
||||
58
backend/src/app/api/admin/lookup/route.ts
Normal file
58
backend/src/app/api/admin/lookup/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
87
backend/src/app/api/admin/punish/route.ts
Normal file
87
backend/src/app/api/admin/punish/route.ts
Normal 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 });
|
||||
}
|
||||
22
backend/src/app/api/admin/queue/route.ts
Normal file
22
backend/src/app/api/admin/queue/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
backend/src/app/api/auth/[...nextauth]/route.ts
Normal file
3
backend/src/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
34
backend/src/app/api/auth/about-me/route.ts
Normal file
34
backend/src/app/api/auth/about-me/route.ts
Normal 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 });
|
||||
}
|
||||
25
backend/src/app/api/auth/delete/route.ts
Normal file
25
backend/src/app/api/auth/delete/route.ts
Normal 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 });
|
||||
}
|
||||
37
backend/src/app/api/auth/name/route.ts
Normal file
37
backend/src/app/api/auth/name/route.ts
Normal 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 });
|
||||
}
|
||||
85
backend/src/app/api/auth/picture/route.ts
Normal file
85
backend/src/app/api/auth/picture/route.ts
Normal 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 });
|
||||
}
|
||||
6
backend/src/app/api/auth/signin/[provider]/route.ts
Normal file
6
backend/src/app/api/auth/signin/[provider]/route.ts
Normal 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);
|
||||
}
|
||||
55
backend/src/app/api/mii/[id]/delete/route.ts
Normal file
55
backend/src/app/api/mii/[id]/delete/route.ts
Normal 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 });
|
||||
}
|
||||
258
backend/src/app/api/mii/[id]/edit/route.ts
Normal file
258
backend/src/app/api/mii/[id]/edit/route.ts
Normal 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 });
|
||||
}
|
||||
38
backend/src/app/api/mii/[id]/info/route.ts
Normal file
38
backend/src/app/api/mii/[id]/info/route.ts
Normal 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);
|
||||
}
|
||||
59
backend/src/app/api/mii/[id]/like/route.ts
Normal file
59
backend/src/app/api/mii/[id]/like/route.ts
Normal 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 });
|
||||
}
|
||||
28
backend/src/app/api/mii/has-liked/route.ts
Normal file
28
backend/src/app/api/mii/has-liked/route.ts
Normal 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));
|
||||
}
|
||||
168
backend/src/app/api/mii/list/route.ts
Normal file
168
backend/src/app/api/mii/list/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
25
backend/src/app/api/profile/[id]/info/route.ts
Normal file
25
backend/src/app/api/profile/[id]/info/route.ts
Normal 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);
|
||||
}
|
||||
106
backend/src/app/api/report/route.ts
Normal file
106
backend/src/app/api/report/route.ts
Normal 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 });
|
||||
}
|
||||
48
backend/src/app/api/return/route.ts
Normal file
48
backend/src/app/api/return/route.ts
Normal 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 });
|
||||
}
|
||||
326
backend/src/app/api/submit/route.ts
Normal file
326
backend/src/app/api/submit/route.ts
Normal 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 });
|
||||
}
|
||||
50
backend/src/app/edit/[id]/page.tsx
Normal file
50
backend/src/app/edit/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
115
backend/src/app/mii/[id]/image/route.ts
Normal file
115
backend/src/app/mii/[id]/image/route.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
122
backend/src/app/off-the-island/page.tsx
Normal file
122
backend/src/app/off-the-island/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
backend/src/app/out/page.tsx
Normal file
72
backend/src/app/out/page.tsx
Normal 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
9
backend/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export default function IndexPage() {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<p>TomodachiShare API</p>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
27
backend/src/app/profile/[id]/picture/route.ts
Normal file
27
backend/src/app/profile/[id]/picture/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
backend/src/app/random/page.tsx
Normal file
20
backend/src/app/random/page.tsx
Normal 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}`);
|
||||
}
|
||||
47
backend/src/app/report/mii/[id]/page.tsx
Normal file
47
backend/src/app/report/mii/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
backend/src/app/report/user/[id]/page.tsx
Normal file
40
backend/src/app/report/user/[id]/page.tsx
Normal 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
12
backend/src/app/robots.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
80
backend/src/app/sitemap.ts
Normal file
80
backend/src/app/sitemap.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
79
backend/src/components/report/mii-form.tsx
Normal file
79
backend/src/components/report/mii-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
backend/src/components/report/reason-selector.tsx
Normal file
62
backend/src/components/report/reason-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
backend/src/components/report/user-form.tsx
Normal file
75
backend/src/components/report/user-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
backend/src/components/submit-button.tsx
Normal file
31
backend/src/components/submit-button.tsx
Normal 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
49
backend/src/lib/auth.ts
Normal 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
223
backend/src/lib/images.tsx
Normal 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
|
||||
7
backend/src/lib/prisma.ts
Normal file
7
backend/src/lib/prisma.ts
Normal 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;
|
||||
109
backend/src/lib/rate-limit.ts
Normal file
109
backend/src/lib/rate-limit.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
backend/src/lib/settings.ts
Normal file
4
backend/src/lib/settings.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const settings = {
|
||||
canSubmit: true,
|
||||
queueEnabled: true,
|
||||
};
|
||||
18
backend/src/lib/utils.ts
Normal file
18
backend/src/lib/utils.ts
Normal 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
2
backend/src/types.d.ts
vendored
Normal 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
42
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue