diff --git a/.gitignore b/.gitignore index 479cff1..56a54a6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ pnpm-debug.log* coverage .DS_Store prisma/dev.db +public/uploads diff --git a/README.md b/README.md index 47d6b97..bc40281 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ npm run db:seed npm run dev ``` 6. Open `http://localhost:3000` +7. Avatar uploads are stored in `public/uploads/avatars`. ## Environment Variables - `DATABASE_URL`: PostgreSQL connection URL @@ -83,6 +84,7 @@ docker compose up --build -d 4. Open `http://localhost:3000`. The app container runs `npm run db:migrate` before `npm run start`. +Avatar uploads are persisted in the `avatar_uploads` Docker volume. ## Security Notes - All important writes are server-side validated (Zod + method-specific checks) diff --git a/actions/profile.ts b/actions/profile.ts index b16cf4f..bf47cb4 100644 --- a/actions/profile.ts +++ b/actions/profile.ts @@ -1,5 +1,8 @@ "use server"; +import { randomUUID } from "crypto"; +import { mkdir, unlink, writeFile } from "fs/promises"; +import { join } from "path"; import { Prisma } from "@prisma/client"; import { revalidatePath } from "next/cache"; import { db } from "@/lib/db"; @@ -17,15 +20,89 @@ function parseCheckboxValue(value: FormDataEntryValue | null) { 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 = { + "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 { const user = await requireCurrentUser(); 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({ username: normalizeUsername(String(formData.get("username") ?? "")), displayName: sanitizePlainText(String(formData.get("displayName") ?? "")), bio: sanitizeOptionalPlainText(String(formData.get("bio") ?? "")) ?? "", - avatarUrl: safeUrl(String(formData.get("avatarUrl") ?? "")) ?? "", + avatarUrl: nextAvatarUrl, themeId: sanitizePlainText(String(formData.get("themeId") ?? DEFAULT_THEME_ID)), isPublic: parseCheckboxValue(formData.get("isPublic")) }); @@ -64,6 +141,10 @@ export async function updateProfileAction(formData: FormData): Promise(null); const [themeId, setThemeId] = useState(initialValues.themeId); const [isPublic, setIsPublic] = useState(initialValues.isPublic); @@ -46,6 +47,9 @@ export function ProfileForm({ initialValues, themes }: ProfileFormProps) { formData.set("displayName", displayName); formData.set("bio", bio); formData.set("avatarUrl", avatarUrl); + if (avatarFile) { + formData.set("avatarFile", avatarFile); + } formData.set("themeId", themeId); if (isPublic) { formData.set("isPublic", "on"); @@ -91,6 +95,15 @@ export function ProfileForm({ initialValues, themes }: ProfileFormProps) {