diff --git a/prisma/migrations/20250509211916_profile_pictures/migration.sql b/prisma/migrations/20250509211916_profile_pictures/migration.sql new file mode 100644 index 0000000..ea5bde7 --- /dev/null +++ b/prisma/migrations/20250509211916_profile_pictures/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "imageUpdatedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 878bf24..5f50162 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,7 @@ model User { updatedAt DateTime @updatedAt usernameUpdatedAt DateTime? + imageUpdatedAt DateTime? accounts Account[] sessions Session[] diff --git a/src/app/api/auth/display-name/route.ts b/src/app/api/auth/display-name/route.ts index 2d0a0ca..45e7647 100644 --- a/src/app/api/auth/display-name/route.ts +++ b/src/app/api/auth/display-name/route.ts @@ -10,7 +10,7 @@ export async function PATCH(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const rateLimit = new RateLimit(request, 1); + const rateLimit = new RateLimit(request, 3); const check = await rateLimit.handle(); if (check) return check; diff --git a/src/app/api/auth/picture/route.ts b/src/app/api/auth/picture/route.ts new file mode 100644 index 0000000..d531514 --- /dev/null +++ b/src/app/api/auth/picture/route.ts @@ -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 30 days + const user = await prisma.user.findUnique({ where: { id: Number(session.user.id) } }); + if (user && user.imageUpdatedAt) { + const timePeriod = dayjs().subtract(30, "days"); + const lastUpdate = dayjs(user.imageUpdatedAt); + + if (lastUpdate.isAfter(timePeriod)) return rateLimit.sendResponse({ error: "Profile picture was changed in the last 30 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.errors[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.webp`, 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 webpBuffer = await sharp(buffer).resize({ width: 128, height: 128 }).webp({ quality: 85 }).toBuffer(); + const fileLocation = path.join(uploadsDirectory, `${session.user.id}.webp`); + + await fs.writeFile(fileLocation, webpBuffer); + } 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 }); +} diff --git a/src/app/api/auth/username/route.ts b/src/app/api/auth/username/route.ts index 8d86a1a..7e9c508 100644 --- a/src/app/api/auth/username/route.ts +++ b/src/app/api/auth/username/route.ts @@ -12,7 +12,7 @@ export async function PATCH(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const rateLimit = new RateLimit(request, 1); + const rateLimit = new RateLimit(request, 3); const check = await rateLimit.handle(); if (check) return check; diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts index a329eed..81f9ff2 100644 --- a/src/app/api/mii/[id]/edit/route.ts +++ b/src/app/api/mii/[id]/edit/route.ts @@ -14,7 +14,7 @@ import { idSchema, nameSchema, tagsSchema } from "@/lib/schemas"; import { validateImage } from "@/lib/images"; import { RateLimit } from "@/lib/rate-limit"; -const uploadsDirectory = path.join(process.cwd(), "public", "mii"); +const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); const editSchema = z.object({ name: nameSchema.optional(), diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index 6266402..9e78774 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -19,7 +19,7 @@ import { convertQrCode } from "@/lib/qr-codes"; import Mii from "@/lib/mii.js/mii"; import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; -const uploadsDirectory = path.join(process.cwd(), "uploads"); +const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); const submitSchema = z.object({ name: nameSchema, diff --git a/src/app/mii/[id]/image/route.ts b/src/app/mii/[id]/image/route.ts index d72eedb..1b4dc8a 100644 --- a/src/app/mii/[id]/image/route.ts +++ b/src/app/mii/[id]/image/route.ts @@ -29,7 +29,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.errors[0].message }, 400); const { type: imageType } = searchParamsParsed.data; - const filePath = path.join(process.cwd(), "uploads", miiId.toString(), `${imageType}.webp`); + const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}.webp`); try { const buffer = await fs.readFile(filePath); diff --git a/src/app/profile/[id]/picture/route.ts b/src/app/profile/[id]/picture/route.ts new file mode 100644 index 0000000..7f22ca8 --- /dev/null +++ b/src/app/profile/[id]/picture/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; + +import fs from "fs/promises"; +import path from "path"; + +import { idSchema } from "@/lib/schemas"; +import { RateLimit } from "@/lib/rate-limit"; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const rateLimit = new RateLimit(request, 16); + 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.errors[0].message }, 400); + const userId = parsed.data; + + const filePath = path.join(process.cwd(), "uploads", "user", `${userId}.webp`); + + try { + const buffer = await fs.readFile(filePath); + return new NextResponse(buffer); + } catch { + return rateLimit.sendResponse({ error: "Image not found" }, 404); + } +} diff --git a/src/components/profile-settings/index.tsx b/src/components/profile-settings/index.tsx index ea0fae5..a37d919 100644 --- a/src/components/profile-settings/index.tsx +++ b/src/components/profile-settings/index.tsx @@ -6,6 +6,7 @@ import dayjs from "dayjs"; import { displayNameSchema, usernameSchema } from "@/lib/schemas"; +import ProfilePictureSettings from "./profile-picture"; import SubmitDialogButton from "./submit-dialog-button"; import DeleteAccount from "./delete-account"; @@ -80,12 +81,13 @@ export default function ProfileSettings() {
+ {/* Profile Picture */} + + {/* Change Name */}
- +

This is a display name shown on your profile — feel free to change it anytime

@@ -103,7 +105,7 @@ export default function ProfileSettings() { error={displayNameChangeError} onSubmit={handleSubmitDisplayNameChange} > -
+

New display name:

'{displayName}'

@@ -114,9 +116,7 @@ export default function ProfileSettings() { {/* Change Username */}
- +

Your unique tag on the site. Can only be changed once every 90 days

@@ -142,7 +142,7 @@ export default function ProfileSettings() { {usernameDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}.

-
+

New username:

'@{username}'

@@ -160,9 +160,7 @@ export default function ProfileSettings() { {/* Delete Account */}
- +

This will permanently remove your account and all uploaded Miis. This action cannot be undone

diff --git a/src/components/profile-settings/profile-picture.tsx b/src/components/profile-settings/profile-picture.tsx new file mode 100644 index 0000000..0d33eb3 --- /dev/null +++ b/src/components/profile-settings/profile-picture.tsx @@ -0,0 +1,125 @@ +import { useRouter } from "next/navigation"; +import Image from "next/image"; + +import { useCallback, useState } from "react"; +import { FileWithPath, useDropzone } from "react-dropzone"; + +import { Icon } from "@iconify/react"; +import dayjs from "dayjs"; + +import SubmitDialogButton from "./submit-dialog-button"; + +export default function ProfilePictureSettings() { + const router = useRouter(); + + const [error, setError] = useState(undefined); + const [newPicture, setNewPicture] = useState(); + + const changeDate = dayjs().add(30, "days"); + + const handleSubmit = async (close: () => void) => { + const formData = new FormData(); + if (newPicture) formData.append("image", newPicture); + + const response = await fetch("/api/auth/picture", { + method: "PATCH", + body: formData, + }); + + if (!response.ok) { + const { error } = await response.json(); + setError(error); + return; + } + + close(); + router.refresh(); + }; + + const handleDrop = useCallback((acceptedFiles: FileWithPath[]) => { + if (!acceptedFiles[0]) return; + setNewPicture(acceptedFiles[0]); + }, []); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop: handleDrop, + maxFiles: 1, + accept: { + "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".webp", ".heic"], + }, + }); + + return ( +
+
+ +

Manage your profile picture. Can only be changed once every 30 days.

+
+ +
+
+
+ {newPicture ? ( + new profile picture + ) : ( + <> + + +

+ Drag and drop your profile picture here +
+ or click to open +

+ + )} +
+
+ +
+ {newPicture && ( + + )} + +

+ After submitting, you can change it again on{" "} + {changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}. +

+ +
+

New profile picture:

+ new profile picture +
+
+
+
+
+ ); +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index b27d887..fde1509 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -35,9 +35,9 @@ export const tagsSchema = z .max(8, { message: "There cannot be more than 8 tags" }); export const idSchema = z.coerce - .number({ message: "Mii ID must be a number" }) - .int({ message: "Mii ID must be an integer" }) - .positive({ message: "Mii ID must be valid" }); + .number({ message: "ID must be a number" }) + .int({ message: "ID must be an integer" }) + .positive({ message: "ID must be valid" }); // Account Info export const usernameSchema = z