diff --git a/app/api/avatars/upload/route.ts b/app/api/avatars/upload/route.ts new file mode 100644 index 0000000..4365f26 --- /dev/null +++ b/app/api/avatars/upload/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server" +import { AvatarImageError, processAvatarImage, validateAvatarInput } from "@/lib/processAvatarImage" +import { getAuthenticatedUser } from "@/lib/supabaseServer" + +export const runtime = "nodejs" + +export async function POST(request: NextRequest) { + try { + const authHeader = request.headers.get("Authorization") + const accessToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null + + if (!accessToken) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const auth = await getAuthenticatedUser(accessToken) + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const formData = await request.formData() + const file = formData.get("file") + + if (!file || !(file instanceof File)) { + return NextResponse.json({ error: "No image file provided" }, { status: 400 }) + } + + validateAvatarInput(file) + + const inputBuffer = Buffer.from(await file.arrayBuffer()) + const processedBuffer = await processAvatarImage(inputBuffer) + + const fileName = `${crypto.randomUUID()}.webp` + const { error: uploadError } = await auth.supabase.storage + .from("avatars") + .upload(fileName, processedBuffer, { + contentType: "image/webp", + upsert: false + }) + + if (uploadError) { + console.error("Avatar upload error:", uploadError) + return NextResponse.json({ error: "Failed to upload image" }, { status: 500 }) + } + + const { + data: { publicUrl } + } = auth.supabase.storage.from("avatars").getPublicUrl(fileName) + + return NextResponse.json({ url: publicUrl }) + } catch (error) { + if (error instanceof AvatarImageError) { + return NextResponse.json({ error: error.message }, { status: error.statusCode }) + } + + console.error("Avatar upload error:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/app/components/Nametag.tsx b/app/components/Nametag.tsx index a676597..2a1dbd3 100644 --- a/app/components/Nametag.tsx +++ b/app/components/Nametag.tsx @@ -579,7 +579,7 @@ const PhotoFrame = styled.div<{ $error?: boolean }>` const Avatar = styled.img` width: 100%; height: 100%; - object-fit: cover; + object-fit: fill; position: relative; z-index: 0; ` diff --git a/app/setup/page.tsx b/app/setup/page.tsx index caab40b..ea94bfc 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react" import { useRouter } from "next/navigation" import { supabaseClient } from "../../lib/supabaseClient" import { updateProfileCache } from "../../lib/profileCache" +import { uploadAvatar } from "../../lib/uploadAvatar" import { PotionBackground } from "../components/PotionBackground" import { Nametag } from "../components/Nametag" import { TextInput } from "../components/TextInput" @@ -174,19 +175,7 @@ export default function Setup() { setUploading(true) try { - const fileExt = file.name.split(".").pop() - const fileName = `${Math.random().toString(36).substring(2)}.${fileExt}` - const filePath = `${fileName}` - - const { error: uploadError } = await supabaseClient.storage - .from("avatars") - .upload(filePath, file) - - if (uploadError) throw uploadError - - const { - data: { publicUrl } - } = supabaseClient.storage.from("avatars").getPublicUrl(filePath) + const publicUrl = await uploadAvatar(file) // Clear photo error immediately when photo is uploaded if (hasAttemptedSubmit && validationErrors.profilePhoto) { @@ -194,7 +183,7 @@ export default function Setup() { } return publicUrl - } catch (error: any) { + } catch (error: unknown) { console.error("Error uploading image:", error) throw error } finally { diff --git a/app/whois/ProfileDisplay.tsx b/app/whois/ProfileDisplay.tsx index 36e4dfc..e559187 100644 --- a/app/whois/ProfileDisplay.tsx +++ b/app/whois/ProfileDisplay.tsx @@ -3,6 +3,7 @@ import styled from "styled-components" import { useState } from "react" import { supabaseClient } from "../../lib/supabaseClient" import { updateProfileCache } from "../../lib/profileCache" +import { uploadAvatar } from "../../lib/uploadAvatar" import { PotionBackground } from "../components/PotionBackground" import { Nametag } from "../components/Nametag" import { TagCloudSection } from "../components/TagCloudSection" @@ -61,22 +62,8 @@ export function ProfileDisplay({ setUploading(true) try { - const fileExt = file.name.split(".").pop() - const fileName = `${Math.random().toString(36).substring(2)}.${fileExt}` - const filePath = `${fileName}` - - const { error: uploadError } = await supabaseClient.storage - .from("avatars") - .upload(filePath, file) - - if (uploadError) throw uploadError - - const { - data: { publicUrl } - } = supabaseClient.storage.from("avatars").getPublicUrl(filePath) - - return publicUrl - } catch (error: any) { + return await uploadAvatar(file) + } catch (error: unknown) { console.error("Error uploading image:", error) throw error } finally { diff --git a/lib/avatarImageConfig.ts b/lib/avatarImageConfig.ts new file mode 100644 index 0000000..7fd50f6 --- /dev/null +++ b/lib/avatarImageConfig.ts @@ -0,0 +1,6 @@ +export const AVATAR_MAX_DIMENSION = 512 +export const AVATAR_MAX_INPUT_BYTES = 4 * 1024 * 1024 +export const AVATAR_WEBP_QUALITY = 85 + +export const AVATAR_ALLOWED_MIME_TYPES = ["image/jpeg", "image/png"] as const +export const AVATAR_ALLOWED_EXTENSIONS = ["jpg", "jpeg", "png"] as const diff --git a/lib/processAvatarImage.ts b/lib/processAvatarImage.ts new file mode 100644 index 0000000..40cd1ae --- /dev/null +++ b/lib/processAvatarImage.ts @@ -0,0 +1,69 @@ +import sharp from "sharp" +import { + AVATAR_ALLOWED_EXTENSIONS, + AVATAR_ALLOWED_MIME_TYPES, + AVATAR_MAX_DIMENSION, + AVATAR_MAX_INPUT_BYTES, + AVATAR_WEBP_QUALITY +} from "./avatarImageConfig" + +export class AvatarImageError extends Error { + statusCode: number + + constructor(message: string, statusCode: number) { + super(message) + this.name = "AvatarImageError" + this.statusCode = statusCode + } +} + +type AvatarInputFile = { + size: number + type: string + name: string +} + +function hasAllowedExtension(fileName: string): boolean { + const ext = fileName.split(".").pop()?.toLowerCase() + return AVATAR_ALLOWED_EXTENSIONS.includes(ext as (typeof AVATAR_ALLOWED_EXTENSIONS)[number]) +} + +function hasAllowedMimeType(mimeType: string): boolean { + return AVATAR_ALLOWED_MIME_TYPES.includes(mimeType as (typeof AVATAR_ALLOWED_MIME_TYPES)[number]) +} + +export function validateAvatarInput(file: AvatarInputFile): void { + if (file.size > AVATAR_MAX_INPUT_BYTES) { + throw new AvatarImageError("Image must be 4MB or smaller", 413) + } + + if (!hasAllowedMimeType(file.type) && !hasAllowedExtension(file.name)) { + throw new AvatarImageError("Only JPG and PNG images are allowed", 415) + } +} + +export async function processAvatarImage(input: Buffer): Promise { + try { + const image = sharp(input) + const metadata = await image.metadata() + + if (metadata.format !== "jpeg" && metadata.format !== "png") { + throw new AvatarImageError("Only JPG and PNG images are allowed", 415) + } + + return await image + .rotate() + .resize(AVATAR_MAX_DIMENSION, AVATAR_MAX_DIMENSION, { + fit: "inside", + withoutEnlargement: true + }) + .webp({ quality: AVATAR_WEBP_QUALITY }) + .toBuffer() + } catch (error) { + if (error instanceof AvatarImageError) { + throw error + } + + throw new AvatarImageError("Invalid or unsupported image file", 415) + } +} diff --git a/lib/supabaseServer.ts b/lib/supabaseServer.ts new file mode 100644 index 0000000..779935c --- /dev/null +++ b/lib/supabaseServer.ts @@ -0,0 +1,42 @@ +import { createClient, SupabaseClient, User } from "@supabase/supabase-js" + +function getSupabaseEnv() { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error( + "Supabase environment variables are not set. Please set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY" + ) + } + + return { supabaseUrl, supabaseAnonKey } +} + +export function createUserScopedSupabaseClient(accessToken: string): SupabaseClient { + const { supabaseUrl, supabaseAnonKey } = getSupabaseEnv() + + return createClient(supabaseUrl, supabaseAnonKey, { + global: { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + }) +} + +export async function getAuthenticatedUser( + accessToken: string +): Promise<{ user: User; supabase: SupabaseClient } | null> { + const supabase = createUserScopedSupabaseClient(accessToken) + const { + data: { user }, + error + } = await supabase.auth.getUser() + + if (error || !user) { + return null + } + + return { user, supabase } +} diff --git a/lib/uploadAvatar.ts b/lib/uploadAvatar.ts new file mode 100644 index 0000000..e191e2b --- /dev/null +++ b/lib/uploadAvatar.ts @@ -0,0 +1,44 @@ +import { supabaseClient } from "./supabaseClient" + +export class AvatarUploadError extends Error { + statusCode?: number + + constructor(message: string, statusCode?: number) { + super(message) + this.name = "AvatarUploadError" + this.statusCode = statusCode + } +} + +export async function uploadAvatar(file: File): Promise { + const { + data: { session } + } = await supabaseClient.auth.getSession() + + if (!session) { + throw new AvatarUploadError("You must be logged in to upload a photo", 401) + } + + const formData = new FormData() + formData.append("file", file) + + const response = await fetch("/api/avatars/upload", { + method: "POST", + headers: { + Authorization: `Bearer ${session.access_token}` + }, + body: formData + }) + + const body = (await response.json().catch(() => ({}))) as { url?: string; error?: string } + + if (!response.ok) { + throw new AvatarUploadError(body.error ?? "Failed to upload image", response.status) + } + + if (!body.url) { + throw new AvatarUploadError("Invalid response from upload server", 500) + } + + return body.url +} diff --git a/package.json b/package.json index bc3dfa5..dff9fb3 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "next": "14.2.4", "react": "^18", "react-dom": "^18", + "sharp": "^0.35.3", "sqlite3": "^5.1.7", "styled-components": "^6.1.18" },