feat(profile): implement avatar upload functionality and update profile handling
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ pnpm-debug.log*
|
|||||||
coverage
|
coverage
|
||||||
.DS_Store
|
.DS_Store
|
||||||
prisma/dev.db
|
prisma/dev.db
|
||||||
|
public/uploads
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ npm run db:seed
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
6. Open `http://localhost:3000`
|
6. Open `http://localhost:3000`
|
||||||
|
7. Avatar uploads are stored in `public/uploads/avatars`.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
- `DATABASE_URL`: PostgreSQL connection URL
|
- `DATABASE_URL`: PostgreSQL connection URL
|
||||||
@@ -83,6 +84,7 @@ docker compose up --build -d
|
|||||||
4. Open `http://localhost:3000`.
|
4. Open `http://localhost:3000`.
|
||||||
|
|
||||||
The app container runs `npm run db:migrate` before `npm run start`.
|
The app container runs `npm run db:migrate` before `npm run start`.
|
||||||
|
Avatar uploads are persisted in the `avatar_uploads` Docker volume.
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
- All important writes are server-side validated (Zod + method-specific checks)
|
- All important writes are server-side validated (Zod + method-specific checks)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { mkdir, unlink, writeFile } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@@ -17,15 +20,89 @@ function parseCheckboxValue(value: FormDataEntryValue | null) {
|
|||||||
return value === "on" || value === "true";
|
return value === "on" || value === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AVATAR_UPLOAD_DIR = join(process.cwd(), "public", "uploads", "avatars");
|
||||||
|
const AVATAR_WEB_PREFIX = "/uploads/avatars/";
|
||||||
|
const MAX_AVATAR_SIZE_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
const MIME_TO_EXTENSION: Record<string, string> = {
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/gif": ".gif"
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deleteOldUploadedAvatar(avatarUrl: string | null | undefined) {
|
||||||
|
if (!avatarUrl || !avatarUrl.startsWith(AVATAR_WEB_PREFIX)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = avatarUrl.slice(AVATAR_WEB_PREFIX.length);
|
||||||
|
if (!fileName || fileName.includes("/") || fileName.includes("\\")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = join(AVATAR_UPLOAD_DIR, fileName);
|
||||||
|
try {
|
||||||
|
await unlink(fullPath);
|
||||||
|
} catch {
|
||||||
|
// no-op, file may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAvatarFile(file: File) {
|
||||||
|
if (file.size <= 0) {
|
||||||
|
return { success: false as const, message: "Selected avatar file is empty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_AVATAR_SIZE_BYTES) {
|
||||||
|
return { success: false as const, message: "Avatar file is too large (max 4MB)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = MIME_TO_EXTENSION[file.type];
|
||||||
|
if (!extension) {
|
||||||
|
return { success: false as const, message: "Avatar must be PNG, JPG, WEBP, or GIF" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(AVATAR_UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const fileName = `${randomUUID()}${extension}`;
|
||||||
|
const fullPath = join(AVATAR_UPLOAD_DIR, fileName);
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
await writeFile(fullPath, new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
avatarUrl: `${AVATAR_WEB_PREFIX}${fileName}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateProfileAction(formData: FormData): Promise<ActionResult> {
|
export async function updateProfileAction(formData: FormData): Promise<ActionResult> {
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
const currentUsername = user.profile?.username;
|
const currentUsername = user.profile?.username;
|
||||||
|
const existingAvatarUrl = user.profile?.avatarUrl ?? null;
|
||||||
|
const avatarFile = formData.get("avatarFile");
|
||||||
|
const rawAvatarUrl = String(formData.get("avatarUrl") ?? "");
|
||||||
|
const sanitizedAvatarUrl = rawAvatarUrl.startsWith(AVATAR_WEB_PREFIX)
|
||||||
|
? rawAvatarUrl
|
||||||
|
: (safeUrl(rawAvatarUrl) ?? "");
|
||||||
|
|
||||||
|
let nextAvatarUrl = sanitizedAvatarUrl;
|
||||||
|
let uploadedAvatarUrl: string | null = null;
|
||||||
|
|
||||||
|
if (avatarFile instanceof File && avatarFile.size > 0) {
|
||||||
|
const uploaded = await saveAvatarFile(avatarFile);
|
||||||
|
if (!uploaded.success) {
|
||||||
|
return { success: false, message: uploaded.message };
|
||||||
|
}
|
||||||
|
nextAvatarUrl = uploaded.avatarUrl;
|
||||||
|
uploadedAvatarUrl = uploaded.avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = profileSchema.safeParse({
|
const parsed = profileSchema.safeParse({
|
||||||
username: normalizeUsername(String(formData.get("username") ?? "")),
|
username: normalizeUsername(String(formData.get("username") ?? "")),
|
||||||
displayName: sanitizePlainText(String(formData.get("displayName") ?? "")),
|
displayName: sanitizePlainText(String(formData.get("displayName") ?? "")),
|
||||||
bio: sanitizeOptionalPlainText(String(formData.get("bio") ?? "")) ?? "",
|
bio: sanitizeOptionalPlainText(String(formData.get("bio") ?? "")) ?? "",
|
||||||
avatarUrl: safeUrl(String(formData.get("avatarUrl") ?? "")) ?? "",
|
avatarUrl: nextAvatarUrl,
|
||||||
themeId: sanitizePlainText(String(formData.get("themeId") ?? DEFAULT_THEME_ID)),
|
themeId: sanitizePlainText(String(formData.get("themeId") ?? DEFAULT_THEME_ID)),
|
||||||
isPublic: parseCheckboxValue(formData.get("isPublic"))
|
isPublic: parseCheckboxValue(formData.get("isPublic"))
|
||||||
});
|
});
|
||||||
@@ -64,6 +141,10 @@ export async function updateProfileAction(formData: FormData): Promise<ActionRes
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (nextAvatarUrl !== existingAvatarUrl) {
|
||||||
|
await deleteOldUploadedAvatar(existingAvatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (currentUsername) {
|
if (currentUsername) {
|
||||||
revalidatePath(`/u/${currentUsername}`);
|
revalidatePath(`/u/${currentUsername}`);
|
||||||
}
|
}
|
||||||
@@ -73,6 +154,10 @@ export async function updateProfileAction(formData: FormData): Promise<ActionRes
|
|||||||
|
|
||||||
return { success: true, message: "Profile saved" };
|
return { success: true, message: "Profile saved" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (uploadedAvatarUrl) {
|
||||||
|
await deleteOldUploadedAvatar(uploadedAvatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||||
return { success: false, message: "Username is already taken" };
|
return { success: false, message: "Username is already taken" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function ProfileForm({ initialValues, themes }: ProfileFormProps) {
|
|||||||
const [displayName, setDisplayName] = useState(initialValues.displayName);
|
const [displayName, setDisplayName] = useState(initialValues.displayName);
|
||||||
const [bio, setBio] = useState(initialValues.bio);
|
const [bio, setBio] = useState(initialValues.bio);
|
||||||
const [avatarUrl, setAvatarUrl] = useState(initialValues.avatarUrl);
|
const [avatarUrl, setAvatarUrl] = useState(initialValues.avatarUrl);
|
||||||
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
const [themeId, setThemeId] = useState(initialValues.themeId);
|
const [themeId, setThemeId] = useState(initialValues.themeId);
|
||||||
const [isPublic, setIsPublic] = useState(initialValues.isPublic);
|
const [isPublic, setIsPublic] = useState(initialValues.isPublic);
|
||||||
|
|
||||||
@@ -46,6 +47,9 @@ export function ProfileForm({ initialValues, themes }: ProfileFormProps) {
|
|||||||
formData.set("displayName", displayName);
|
formData.set("displayName", displayName);
|
||||||
formData.set("bio", bio);
|
formData.set("bio", bio);
|
||||||
formData.set("avatarUrl", avatarUrl);
|
formData.set("avatarUrl", avatarUrl);
|
||||||
|
if (avatarFile) {
|
||||||
|
formData.set("avatarFile", avatarFile);
|
||||||
|
}
|
||||||
formData.set("themeId", themeId);
|
formData.set("themeId", themeId);
|
||||||
if (isPublic) {
|
if (isPublic) {
|
||||||
formData.set("isPublic", "on");
|
formData.set("isPublic", "on");
|
||||||
@@ -91,6 +95,15 @@ export function ProfileForm({ initialValues, themes }: ProfileFormProps) {
|
|||||||
<Textarea value={bio} onChange={(event) => setBio(event.target.value)} maxLength={280} rows={4} />
|
<Textarea value={bio} onChange={(event) => setBio(event.target.value)} maxLength={280} rows={4} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-2 text-sm">
|
||||||
|
<span className="form-label">Avatar Upload (PNG, JPG, WEBP, GIF, max 4MB)</span>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span className="form-label">Avatar URL</span>
|
<span className="form-label">Avatar URL</span>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export function ProfileHeader({ displayName, username, bio, avatarUrl }: Profile
|
|||||||
if (!normalizedAvatarUrl) {
|
if (!normalizedAvatarUrl) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (normalizedAvatarUrl.startsWith("/uploads/avatars/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const url = new URL(normalizedAvatarUrl);
|
const url = new URL(normalizedAvatarUrl);
|
||||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ services:
|
|||||||
NEXTAUTH_URL: http://localhost:3000
|
NEXTAUTH_URL: http://localhost:3000
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- avatar_uploads:/app/public/uploads/avatars
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
avatar_uploads:
|
||||||
|
|||||||
@@ -12,8 +12,23 @@ export const profileSchema = z.object({
|
|||||||
avatarUrl: z
|
avatarUrl: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.url("Avatar URL must be a valid URL")
|
|
||||||
.max(500)
|
.max(500)
|
||||||
|
.refine((value) => {
|
||||||
|
if (!value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("/uploads/avatars/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, "Avatar must be a valid URL or uploaded file path")
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
themeId: z.string().trim().min(1),
|
themeId: z.string().trim().min(1),
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {};
|
const nextConfig: NextConfig = {
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: "5mb"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Reference in New Issue
Block a user