Compare commits
30 commits
79b19f4807
...
a42a4126ec
| Author | SHA1 | Date | |
|---|---|---|---|
| a42a4126ec | |||
| 3d2de94acf | |||
| aa20e931ee | |||
| 0bd2d6d565 | |||
| 50bad620ce | |||
| 94eef81b93 | |||
| 59d2b0b2c1 | |||
| 63dbaf13fa | |||
| e81f054e3a | |||
| ce1c7a667a | |||
| 97f0fda25c | |||
| 896dc40553 | |||
| 9795849830 | |||
| 3e87d263da | |||
| 12203901e9 | |||
| 87b885a2f8 | |||
| 11df9261da | |||
| 46202b22b0 | |||
| ae266d5aa0 | |||
| 2f485dfca5 | |||
| f5391d63e6 | |||
| 93f9f42e0b | |||
| efad557caf | |||
| 466d0e5925 | |||
| 41d30b3683 | |||
| 8ef2b18424 | |||
| 1d11cf3f99 | |||
| d208565a61 | |||
| 59bc1d3ace | |||
| 84144c383c |
6
.gitignore
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
node_modules/
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.*
|
.pnp.*
|
||||||
.yarn/*
|
.yarn/*
|
||||||
|
|
@ -14,8 +14,8 @@
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
.next/
|
||||||
/out/
|
backend/out/
|
||||||
certificates/
|
certificates/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
# TomodachiShare Development Instructions
|
# TomodachiShare Development Instructions
|
||||||
|
|
||||||
|
This is probably outdated.
|
||||||
|
|
||||||
Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety.
|
Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
|
||||||
44
Dockerfile
|
|
@ -1,51 +1,39 @@
|
||||||
FROM node:23-alpine AS base
|
FROM node:23-alpine AS base
|
||||||
|
|
||||||
FROM base AS deps
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
RUN apk add --no-cache libc6-compat
|
||||||
COPY prisma ./prisma/
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
RUN corepack enable pnpm && pnpm i --frozen-lockfile
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app /app
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN cd backend && pnpm build
|
||||||
|
|
||||||
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
|
FROM base AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/backend/public ./public
|
||||||
|
COPY --from=builder /app/backend/.next ./.next
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/backend/prisma ./prisma
|
||||||
|
|
||||||
# Create the uploads directory and set ownership
|
RUN mkdir -p /app/.next/standalone/backend/uploads && chown -R nextjs:nodejs /app/.next/standalone/backend/uploads
|
||||||
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
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
CMD ["node", ".next/standalone/backend/server.js"]
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="public/logo.svg" alt="TomodachiShare Logo" width="128" />
|
<img src="backend/public/logo.svg" alt="TomodachiShare Logo" width="128" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center"><a href="https://tomodachishare.com">TomodachiShare</a></h1>
|
<h1 align="center"><a href="https://tomodachishare.com">TomodachiShare</a></h1>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ REDIS_URL="redis://localhost:6379/0"
|
||||||
|
|
||||||
# Used for metadata, sitemaps, etc.
|
# Used for metadata, sitemaps, etc.
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
FRONTEND_URL=http://localhost:4321
|
||||||
|
|
||||||
CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX
|
CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX
|
||||||
CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX
|
CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX
|
||||||
20
backend/next.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
headers: [
|
||||||
|
{ key: "Access-Control-Allow-Origin", value: process.env.NEXT_PUBLIC_FRONTEND_URL || "http://localhost:4321" },
|
||||||
|
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||||
|
{ key: "Access-Control-Allow-Methods", value: "GET,POST,DELETE,OPTIONS" },
|
||||||
|
{ key: "Access-Control-Allow-Headers", value: "Content-Type" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
52
backend/package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"@prisma/client": "^6.19.2",
|
||||||
|
"bit-buffer": "^0.3.0",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
|
"downshift": "^9.3.2",
|
||||||
|
"file-type": "^22.0.1",
|
||||||
|
"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",
|
||||||
|
"redis": "^5.11.0",
|
||||||
|
"satori": "^0.26.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"sjcl-with-all": "1.0.8",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"@tomodachi-share/shared": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
|
"@iconify/react": "^6.0.2",
|
||||||
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@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"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/types.d.ts"
|
||||||
|
},
|
||||||
|
"types": "./src/types.d.ts"
|
||||||
|
}
|
||||||
5082
backend/pnpm-lock.yaml
Normal file
|
|
@ -104,7 +104,6 @@ model Mii {
|
||||||
@@index([gender])
|
@@index([gender])
|
||||||
@@index([makeup])
|
@@index([makeup])
|
||||||
@@index([quarantined, id])
|
@@index([quarantined, id])
|
||||||
@@index([in_queue, quarantined, createdAt(sort: Desc)])
|
|
||||||
@@map("miis")
|
@@map("miis")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
|
@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { idSchema } from "@/lib/schemas";
|
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { settings } from "@/lib/settings";
|
import { settings } from "../../../../lib/settings";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({ success: true, value: settings.canSubmit });
|
return NextResponse.json({ success: true, value: settings.canSubmit });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { idSchema } from "@/lib/schemas";
|
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
@ -5,7 +5,7 @@ import dayjs from "dayjs";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { idSchema } from "@/lib/schemas";
|
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||||
import { PunishmentType } from "@prisma/client";
|
import { PunishmentType } from "@prisma/client";
|
||||||
|
|
||||||
const punishSchema = z.object({
|
const punishSchema = z.object({
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { settings } from "@/lib/settings";
|
import { settings } from "../../../../lib/settings";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({ success: true, value: settings.queueEnabled });
|
return NextResponse.json({ success: true, value: settings.queueEnabled });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { generateMetadataImage } from "@/lib/images";
|
import { generateMetadataImage } from "@/lib/images";
|
||||||
|
|
||||||
export async function PATCH() {
|
export async function POST() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
|
@ -3,10 +3,10 @@ import { profanity } from "@2toad/profanity";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { userNameSchema } from "@/lib/schemas";
|
import { userNameSchema } from "@tomodachi-share/shared/schemas";
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ const formDataSchema = z.object({
|
||||||
image: z.union([z.instanceof(File), z.any()]).optional(),
|
image: z.union([z.instanceof(File), z.any()]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
@ -5,12 +5,12 @@ import path from "path";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { idSchema } from "@/lib/schemas";
|
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
|
@ -10,12 +10,11 @@ import { profanity } from "@2toad/profanity";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { idSchema, nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@/lib/schemas";
|
import { idSchema, nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
|
||||||
import { generateMetadataImage, validateImage } from "@/lib/images";
|
import { generateMetadataImage, validateImage } from "@/lib/images";
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
import { SwitchMiiInstructions } from "@/types";
|
import { minifyInstructions, SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||||
import { minifyInstructions } from "@/lib/switch";
|
import { settings } from "../../../../../lib/settings";
|
||||||
import { settings } from "@/lib/settings";
|
|
||||||
|
|
||||||
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
||||||
|
|
||||||
|
|
@ -42,7 +41,7 @@ const editSchema = z.object({
|
||||||
image3: 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 }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
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
|
|
@ -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 POST(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
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,15 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { searchSchema } from "@tomodachi-share/shared/schemas";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
import { searchSchema } from "@/lib/schemas";
|
export async function GET(request: NextRequest) {
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
import SortSelect from "./sort-select";
|
|
||||||
import Pagination from "./pagination";
|
|
||||||
import FilterMenu from "./filter-menu";
|
|
||||||
import MiiGrid from "./mii-grid";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
searchParams: { [key: string]: string | string[] | undefined };
|
|
||||||
userId?: number; // Profiles
|
|
||||||
parentPage?: "likes" | "admin";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function MiiList({ searchParams, userId, parentPage }: Props) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const parsed = searchSchema.safeParse(searchParams);
|
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
|
||||||
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
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 } = parsed.data;
|
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, parentPage, userId } = parsed.data;
|
||||||
|
|
||||||
// My Likes page
|
// My Likes page
|
||||||
let miiIdsLiked: number[] | undefined = undefined;
|
let miiIdsLiked: number[] | undefined = undefined;
|
||||||
|
|
@ -100,10 +89,12 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
|
||||||
_count: {
|
_count: {
|
||||||
select: { likedBy: true },
|
select: { likedBy: true },
|
||||||
},
|
},
|
||||||
|
// Admin
|
||||||
|
...(parentPage === "admin" && {
|
||||||
|
description: true,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
|
|
||||||
let totalCount: number;
|
let totalCount: number;
|
||||||
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||||
|
|
||||||
|
|
@ -125,29 +116,16 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
|
||||||
where,
|
where,
|
||||||
orderBy,
|
orderBy,
|
||||||
select,
|
select,
|
||||||
skip,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lastPage = Math.ceil(totalCount / limit);
|
const lastPage = Math.ceil(totalCount / limit);
|
||||||
|
|
||||||
return (
|
return NextResponse.json({
|
||||||
<div className="w-full">
|
miis,
|
||||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
|
totalCount,
|
||||||
<div className="flex items-center gap-2">
|
lastPage,
|
||||||
<span className="text-2xl font-bold text-amber-900">{totalCount}</span>
|
});
|
||||||
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
|
|
||||||
<FilterMenu />
|
|
||||||
<SortSelect />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MiiGrid miis={miis} userId={userId} parentPage={parentPage} />
|
|
||||||
<Pagination lastPage={lastPage} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { auth } from "@/lib/auth";
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
|
@ -11,16 +11,14 @@ import { MiiGender, MiiMakeup, MiiPlatform } from "@prisma/client";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@/lib/schemas";
|
import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
import { generateMetadataImage, validateImage } from "@/lib/images";
|
import { generateMetadataImage, validateImage } from "@/lib/images";
|
||||||
import { convertQrCode } from "@/lib/qr-codes";
|
import Mii from "../../../../../shared/src/mii.js/mii";
|
||||||
import Mii from "@/lib/mii.js/mii";
|
import { convertQrCode, minifyInstructions, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared";
|
||||||
import { ThreeDsTomodachiLifeMii } from "@/lib/three-ds-tomodachi-life-mii";
|
|
||||||
|
|
||||||
import { SwitchMiiInstructions } from "@/types";
|
import { SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||||
import { minifyInstructions } from "@/lib/switch";
|
import { settings } from "../../../lib/settings";
|
||||||
import { settings } from "@/lib/settings";
|
|
||||||
|
|
||||||
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
||||||
|
|
||||||
|
|
@ -220,8 +218,7 @@ export async function POST(request: NextRequest) {
|
||||||
// Download the image of the Mii (3DS)
|
// Download the image of the Mii (3DS)
|
||||||
if (platform === "THREE_DS") {
|
if (platform === "THREE_DS") {
|
||||||
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
|
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
|
||||||
if (!studioUrl || new URL(studioUrl).hostname !== "studio.mii.nintendo.com") throw new Error("Invalid studio URL");
|
const studioResponse = await fetch(studioUrl!);
|
||||||
const studioResponse = await fetch(studioUrl);
|
|
||||||
|
|
||||||
if (!studioResponse.ok) {
|
if (!studioResponse.ok) {
|
||||||
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
|
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
|
||||||
|
|
@ -5,7 +5,7 @@ import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { idSchema } from "@/lib/schemas";
|
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
import { generateMetadataImage } from "@/lib/images";
|
import { generateMetadataImage } from "@/lib/images";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
@ -20,7 +20,7 @@ const searchParamsSchema = z.object({
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const rateLimit = new RateLimit(request, 200, "/mii/image");
|
const rateLimit = new RateLimit(request, 200, "/mii/image");
|
||||||
const check = await rateLimit.handleByIp();
|
const check = await rateLimit.handle();
|
||||||
if (check) return check;
|
if (check) return check;
|
||||||
|
|
||||||
const { id: slugId } = await params;
|
const { id: slugId } = await params;
|
||||||
|
|
@ -107,12 +107,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// mii, features are purged on edit; qr-code is immutable. imageN isn't purged, so keep its TTL short.
|
|
||||||
const isStableType = imageType === "mii" || imageType === "qr-code" || imageType === "features";
|
|
||||||
|
|
||||||
return rateLimit.sendResponse(buffer, 200, {
|
return rateLimit.sendResponse(buffer, 200, {
|
||||||
"Content-Type": "image/png",
|
"Content-Type": "image/png",
|
||||||
"X-Robots-Tag": "noindex, noimageindex, nofollow",
|
"X-Robots-Tag": "noindex, noimageindex, nofollow",
|
||||||
"Cache-Control": isStableType ? "public, max-age=3600, stale-while-revalidate=86400" : "public, max-age=60, stale-while-revalidate=30",
|
"Cache-Control": "public, max-age=60, stale-while-revalidate=30",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import dayjs from "dayjs";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
import ReturnToIsland from "@/components/admin/return-to-island";
|
// import ReturnToIsland from "@/components/admin/return-to-island";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Exiled - TomodachiShare",
|
title: "Exiled - TomodachiShare",
|
||||||
|
|
@ -109,7 +109,7 @@ export default async function ExiledPage() {
|
||||||
{activePunishment.type !== "PERM_EXILE" ? (
|
{activePunishment.type !== "PERM_EXILE" ? (
|
||||||
<>
|
<>
|
||||||
<p className="mb-2">Once your punishment ends, you can return by checking the box below.</p>
|
<p className="mb-2">Once your punishment ends, you can return by checking the box below.</p>
|
||||||
<ReturnToIsland hasExpired={hasExpired} />
|
{/* <ReturnToIsland hasExpired={hasExpired} /> */}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import { idSchema } from "@/lib/schemas";
|
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
import { RateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
|
@ -15,6 +15,6 @@ export default async function RandomPage() {
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!randomMii) redirect("/");
|
if (!randomMii) redirect(process.env.NEXT_PUBLIC_FRONTEND_URL || "http://localhost:4321");
|
||||||
redirect(`/mii/${randomMii.id}`);
|
redirect(`${process.env.NEXT_PUBLIC_FRONTEND_URL}/mii/${randomMii.id}`);
|
||||||
}
|
}
|
||||||
|
|
@ -9,15 +9,28 @@ import { prisma } from "@/lib/prisma";
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
providers: [Discord, Github({ issuer: "https://github.com/login/oauth" }), Google],
|
providers: [Discord, Github({ issuer: "https://github.com/login/oauth" }), Google],
|
||||||
pages: {
|
trustHost: true,
|
||||||
signIn: "/login",
|
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,
|
||||||
|
domain: process.env.NODE_ENV === "production" ? ".tomodachishare.com" : "localhost",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
strategy: "database",
|
||||||
|
maxAge: 30 * 24 * 60 * 60,
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async signIn({ user, account, profile }) {
|
async signIn({ user }) {
|
||||||
const blacklist = process.env.BLACKLISTED_EMAILS ? process.env.BLACKLISTED_EMAILS.split(",").map((item) => item.trim().toLowerCase()) : [];
|
const blacklist = process.env.BLACKLISTED_EMAILS ? process.env.BLACKLISTED_EMAILS.split(",").map((item) => item.trim().toLowerCase()) : [];
|
||||||
const email = user?.email?.toLowerCase();
|
const email = user?.email?.toLowerCase();
|
||||||
if (!email) return false;
|
if (!email) return false;
|
||||||
if (account?.provider === "google" && (profile as { email_verified?: boolean })?.email_verified === false) return false;
|
|
||||||
if (blacklist?.some((blocked) => email.endsWith(blocked))) return false;
|
if (blacklist?.some((blocked) => email.endsWith(blocked))) return false;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
@ -29,5 +42,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async redirect({ url, baseUrl }) {
|
||||||
|
return process.env.NEXT_PUBLIC_FRONTEND_URL ?? "http://localhost:4321";
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -190,7 +190,7 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
|
||||||
|
|
||||||
{/* Watermark */}
|
{/* Watermark */}
|
||||||
<div tw="absolute bottom-0 right-0 flex items-center">
|
<div tw="absolute bottom-0 right-0 flex items-center">
|
||||||
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={32} />
|
<img src={`${process.env.NEXT_PUBLIC_FRONTEND_URL}/logo.svg`} height={32} />
|
||||||
{/* I tried using text-orange-400 but it wasn't correct..? */}
|
{/* I tried using text-orange-400 but it wasn't correct..? */}
|
||||||
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
|
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
|
||||||
TomodachiShare
|
TomodachiShare
|
||||||
|
|
@ -68,10 +68,9 @@ export class RateLimit {
|
||||||
|
|
||||||
return { success, limit: this.maxRequests, remaining, expires: expireAt };
|
return { success, limit: this.maxRequests, remaining, expires: expireAt };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fail open — don't block users when Redis is unreachable
|
|
||||||
console.error("Rate limit check failed", error);
|
console.error("Rate limit check failed", error);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
limit: this.maxRequests,
|
limit: this.maxRequests,
|
||||||
remaining: this.maxRequests,
|
remaining: this.maxRequests,
|
||||||
expires: expireAt,
|
expires: expireAt,
|
||||||
|
|
@ -107,13 +106,4 @@ export class RateLimit {
|
||||||
if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429);
|
if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// IP-only variant — skips the session lookup for anonymous read paths like images
|
|
||||||
async handleByIp(): Promise<NextResponse<object | unknown> | undefined> {
|
|
||||||
const ip = this.request.headers.get("CF-Connecting-IP") || this.request.headers.get("X-Forwarded-For")?.split(",")[0] || "anonymous";
|
|
||||||
this.data = await this.check(ip);
|
|
||||||
|
|
||||||
if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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";
|
||||||
|
|
@ -23,6 +23,20 @@
|
||||||
"sjcl-with-all": ["./node_modules/@types/sjcl"]
|
"sjcl-with-all": ["./node_modules/@types/sjcl"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
"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"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
24
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
frontend/README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
63
frontend/index.html
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TomodachiShare</title>
|
||||||
|
<meta name="description" content="Discover and share Mii residents for your Tomodachi Life island!" />
|
||||||
|
<meta name="keywords" content="mii, tomodachi life, nintendo, tomodachishare, tomodachi-share, mii creator, mii collection" />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="TomodachiShare" />
|
||||||
|
<meta property="og:description" content="Discover and share Mii residents for your Tomodachi Life island!" />
|
||||||
|
<meta property="og:image" content="/preview.png" />
|
||||||
|
<meta property="og:url" content="https://tomodachishare.com" />
|
||||||
|
<meta property="og:site_name" content="TomodachiShare" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="TomodachiShare - Discover and Share Your Mii Residents" />
|
||||||
|
<meta name="twitter:description" content="Discover and share Mii residents for your Tomodachi Life island!" />
|
||||||
|
<meta name="twitter:image" content="/preview.png" />
|
||||||
|
<meta name="twitter:creator" content="@trafficlunr" />
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "TomodachiShare",
|
||||||
|
"url": "https://tomodachishare.com",
|
||||||
|
"description": "Discover and share Mii residents for your Tomodachi Life island!",
|
||||||
|
"inLanguage": "en",
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "TomodachiShare",
|
||||||
|
"url": "https://tomodachishare.com",
|
||||||
|
"logo": {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"url": "https://tomodachishare.com/logo.png"
|
||||||
|
},
|
||||||
|
"sameAs": ["https://trafficlunar.net", "https://twitter.com/trafficlunr", "https://bsky.app/profile/trafficlunar.net"]
|
||||||
|
},
|
||||||
|
"potentialAction": {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
"target": "https://tomodachishare.com/?q={search_term_string}",
|
||||||
|
"query-input": "required name=search_term_string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link href="/src/index.css" rel="stylesheet" />
|
||||||
|
<script defer src="https://analytics.trafficlunar.net/script.js" data-website-id="bc530384-9b7d-471a-b2e3-f9859da50c24"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
frontend/package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bprogress/react": "^1.2.7",
|
||||||
|
"@fontsource-variable/lexend": "^5.2.11",
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
|
"@nanostores/react": "^1.1.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@tomodachi-share/shared": "workspace:*",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
|
"downshift": "^9.3.2",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
|
"nanostores": "^1.2.0",
|
||||||
|
"qrcode-generator": "^2.0.4",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-dropzone": "^15.0.0",
|
||||||
|
"react-image-crop": "^11.0.10",
|
||||||
|
"react-router": "^7.14.1",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@iconify/react": "^6.0.2",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
|
"vite": "^8.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
1
frontend/public/favicon.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 |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 536 B After Width: | Height: | Size: 536 B |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
1
frontend/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 |
|
Before Width: | Height: | Size: 645 B After Width: | Height: | Size: 645 B |
|
Before Width: | Height: | Size: 873 KiB After Width: | Height: | Size: 873 KiB |
13
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
User-Agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /*?*page=
|
||||||
|
Disallow: /profile*?*tags=
|
||||||
|
Disallow: /edit/*
|
||||||
|
Disallow: /profile/settings
|
||||||
|
Disallow: /random
|
||||||
|
Disallow: /submit
|
||||||
|
Disallow: /report/mii/*
|
||||||
|
Disallow: /report/user/*
|
||||||
|
Disallow: /admin
|
||||||
|
|
||||||
|
Sitemap: https://api.tomodachishare.com/sitemap.xml
|
||||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |