feat: change profile pictures
I keep forgetting to do things. The edit mii api route has been using the public folder as an uploads directory... whoops...
This commit is contained in:
parent
c5437ed3e7
commit
5514f2ec39
12 changed files with 257 additions and 19 deletions
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "imageUpdatedAt" TIMESTAMP(3);
|
||||||
|
|
@ -19,6 +19,7 @@ model User {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
usernameUpdatedAt DateTime?
|
usernameUpdatedAt DateTime?
|
||||||
|
imageUpdatedAt DateTime?
|
||||||
|
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export async function PATCH(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 1);
|
const rateLimit = new RateLimit(request, 3);
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
if (check) return check;
|
if (check) return check;
|
||||||
|
|
||||||
|
|
|
||||||
85
src/app/api/auth/picture/route.ts
Normal file
85
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 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 });
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ export async function PATCH(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 1);
|
const rateLimit = new RateLimit(request, 3);
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
if (check) return check;
|
if (check) return check;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { idSchema, nameSchema, tagsSchema } from "@/lib/schemas";
|
||||||
import { validateImage } from "@/lib/images";
|
import { validateImage } from "@/lib/images";
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
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({
|
const editSchema = z.object({
|
||||||
name: nameSchema.optional(),
|
name: nameSchema.optional(),
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { convertQrCode } from "@/lib/qr-codes";
|
||||||
import Mii from "@/lib/mii.js/mii";
|
import Mii from "@/lib/mii.js/mii";
|
||||||
import { TomodachiLifeMii } from "@/lib/tomodachi-life-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({
|
const submitSchema = z.object({
|
||||||
name: nameSchema,
|
name: nameSchema,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.errors[0].message }, 400);
|
||||||
const { type: imageType } = searchParamsParsed.data;
|
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 {
|
try {
|
||||||
const buffer = await fs.readFile(filePath);
|
const buffer = await fs.readFile(filePath);
|
||||||
|
|
|
||||||
27
src/app/profile/[id]/picture/route.ts
Normal file
27
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 "@/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import dayjs from "dayjs";
|
||||||
|
|
||||||
import { displayNameSchema, usernameSchema } from "@/lib/schemas";
|
import { displayNameSchema, usernameSchema } from "@/lib/schemas";
|
||||||
|
|
||||||
|
import ProfilePictureSettings from "./profile-picture";
|
||||||
import SubmitDialogButton from "./submit-dialog-button";
|
import SubmitDialogButton from "./submit-dialog-button";
|
||||||
import DeleteAccount from "./delete-account";
|
import DeleteAccount from "./delete-account";
|
||||||
|
|
||||||
|
|
@ -80,12 +81,13 @@ export default function ProfileSettings() {
|
||||||
<hr className="flex-grow border-zinc-300" />
|
<hr className="flex-grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Picture */}
|
||||||
|
<ProfilePictureSettings />
|
||||||
|
|
||||||
{/* Change Name */}
|
{/* Change Name */}
|
||||||
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
|
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="deletion" className="font-semibold">
|
<label className="font-semibold">Change Display Name</label>
|
||||||
Change Display Name
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-zinc-500">This is a display name shown on your profile — feel free to change it anytime</p>
|
<p className="text-sm text-zinc-500">This is a display name shown on your profile — feel free to change it anytime</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -103,7 +105,7 @@ export default function ProfileSettings() {
|
||||||
error={displayNameChangeError}
|
error={displayNameChangeError}
|
||||||
onSubmit={handleSubmitDisplayNameChange}
|
onSubmit={handleSubmitDisplayNameChange}
|
||||||
>
|
>
|
||||||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 px-2 py-1">
|
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1">
|
||||||
<p className="font-semibold">New display name:</p>
|
<p className="font-semibold">New display name:</p>
|
||||||
<p className="indent-4">'{displayName}'</p>
|
<p className="indent-4">'{displayName}'</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -114,9 +116,7 @@ export default function ProfileSettings() {
|
||||||
{/* Change Username */}
|
{/* Change Username */}
|
||||||
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
|
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="deletion" className="font-semibold">
|
<label className="font-semibold">Change Username</label>
|
||||||
Change Username
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-zinc-500">Your unique tag on the site. Can only be changed once every 90 days</p>
|
<p className="text-sm text-zinc-500">Your unique tag on the site. Can only be changed once every 90 days</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -142,7 +142,7 @@ export default function ProfileSettings() {
|
||||||
{usernameDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}.
|
{usernameDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 px-2 py-1">
|
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1">
|
||||||
<p className="font-semibold">New username:</p>
|
<p className="font-semibold">New username:</p>
|
||||||
<p className="indent-4">'@{username}'</p>
|
<p className="indent-4">'@{username}'</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -160,9 +160,7 @@ export default function ProfileSettings() {
|
||||||
{/* Delete Account */}
|
{/* Delete Account */}
|
||||||
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
|
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="deletion" className="font-semibold">
|
<label className="font-semibold">Delete Account</label>
|
||||||
Delete Account
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-zinc-500">This will permanently remove your account and all uploaded Miis. This action cannot be undone</p>
|
<p className="text-sm text-zinc-500">This will permanently remove your account and all uploaded Miis. This action cannot be undone</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
125
src/components/profile-settings/profile-picture.tsx
Normal file
125
src/components/profile-settings/profile-picture.tsx
Normal file
|
|
@ -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<string | undefined>(undefined);
|
||||||
|
const [newPicture, setNewPicture] = useState<FileWithPath | undefined>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="font-semibold">Profile Picture</label>
|
||||||
|
<p className="text-sm text-zinc-500">Manage your profile picture. Can only be changed once every 30 days.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div
|
||||||
|
{...getRootProps({
|
||||||
|
className:
|
||||||
|
"bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border border-2 border-dashed border-amber-500 select-none h-full w-sm",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{newPicture ? (
|
||||||
|
<Image
|
||||||
|
src={URL.createObjectURL(newPicture)}
|
||||||
|
alt="new profile picture"
|
||||||
|
width={128}
|
||||||
|
height={128}
|
||||||
|
className="rounded-full aspect-square border-2 border-amber-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input {...getInputProps({ multiple: false })} />
|
||||||
|
<Icon icon="material-symbols:upload" fontSize={32} />
|
||||||
|
<p className="text-center text-xs">
|
||||||
|
Drag and drop your profile picture here
|
||||||
|
<br />
|
||||||
|
or click to open
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-1 mt-2">
|
||||||
|
{newPicture && (
|
||||||
|
<button
|
||||||
|
data-tooltip="Delete Picture"
|
||||||
|
onClick={() => setNewPicture(undefined)}
|
||||||
|
className="pill button aspect-square !p-1 text-2xl !bg-red-400 !border-red-500"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:trash-outline" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<SubmitDialogButton
|
||||||
|
title="Confirm Profile Picture Change"
|
||||||
|
description="Are you sure? Your profile picture can only be changed every 30 days."
|
||||||
|
error={error}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-zinc-500 mt-2">
|
||||||
|
After submitting, you can change it again on{" "}
|
||||||
|
{changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center">
|
||||||
|
<p className="font-semibold mb-2">New profile picture:</p>
|
||||||
|
<Image
|
||||||
|
src={newPicture ? URL.createObjectURL(newPicture) : "/guest.webp"}
|
||||||
|
alt="new profile picture"
|
||||||
|
width={128}
|
||||||
|
height={128}
|
||||||
|
className="rounded-full aspect-square border-2 border-amber-500 ml-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SubmitDialogButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -35,9 +35,9 @@ export const tagsSchema = z
|
||||||
.max(8, { message: "There cannot be more than 8 tags" });
|
.max(8, { message: "There cannot be more than 8 tags" });
|
||||||
|
|
||||||
export const idSchema = z.coerce
|
export const idSchema = z.coerce
|
||||||
.number({ message: "Mii ID must be a number" })
|
.number({ message: "ID must be a number" })
|
||||||
.int({ message: "Mii ID must be an integer" })
|
.int({ message: "ID must be an integer" })
|
||||||
.positive({ message: "Mii ID must be valid" });
|
.positive({ message: "ID must be valid" });
|
||||||
|
|
||||||
// Account Info
|
// Account Info
|
||||||
export const usernameSchema = z
|
export const usernameSchema = z
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue