feat(profile): implement avatar upload functionality and update profile handling

This commit is contained in:
2026-03-29 15:12:34 +02:00
parent 3b57ad4f37
commit a3964eb047
8 changed files with 131 additions and 3 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ pnpm-debug.log*
coverage
.DS_Store
prisma/dev.db
public/uploads

View File

@@ -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)

View File

@@ -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<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> {
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<ActionRes
}
});
if (nextAvatarUrl !== existingAvatarUrl) {
await deleteOldUploadedAvatar(existingAvatarUrl);
}
if (currentUsername) {
revalidatePath(`/u/${currentUsername}`);
}
@@ -73,6 +154,10 @@ export async function updateProfileAction(formData: FormData): Promise<ActionRes
return { success: true, message: "Profile saved" };
} catch (error) {
if (uploadedAvatarUrl) {
await deleteOldUploadedAvatar(uploadedAvatarUrl);
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return { success: false, message: "Username is already taken" };
}

View File

@@ -34,6 +34,7 @@ export function ProfileForm({ initialValues, themes }: ProfileFormProps) {
const [displayName, setDisplayName] = useState(initialValues.displayName);
const [bio, setBio] = useState(initialValues.bio);
const [avatarUrl, setAvatarUrl] = useState(initialValues.avatarUrl);
const [avatarFile, setAvatarFile] = useState<File | null>(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) {
<Textarea value={bio} onChange={(event) => setBio(event.target.value)} maxLength={280} rows={4} />
</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">
<span className="form-label">Avatar URL</span>
<Input

View File

@@ -28,6 +28,9 @@ export function ProfileHeader({ displayName, username, bio, avatarUrl }: Profile
if (!normalizedAvatarUrl) {
return false;
}
if (normalizedAvatarUrl.startsWith("/uploads/avatars/")) {
return true;
}
try {
const url = new URL(normalizedAvatarUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {

View File

@@ -32,6 +32,9 @@ services:
NEXTAUTH_URL: http://localhost:3000
ports:
- "3000:3000"
volumes:
- avatar_uploads:/app/public/uploads/avatars
volumes:
postgres_data:
avatar_uploads:

View File

@@ -12,8 +12,23 @@ export const profileSchema = z.object({
avatarUrl: z
.string()
.trim()
.url("Avatar URL must be a valid URL")
.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()
.or(z.literal("")),
themeId: z.string().trim().min(1),

View File

@@ -1,5 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
const nextConfig: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "5mb"
}
}
};
export default nextConfig;