Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions app/api/avatars/upload/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
2 changes: 1 addition & 1 deletion app/components/Nametag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
`
Expand Down
17 changes: 3 additions & 14 deletions app/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -174,27 +175,15 @@ 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) {
setValidationErrors((prev) => ({ ...prev, profilePhoto: false }))
}

return publicUrl
} catch (error: any) {
} catch (error: unknown) {
console.error("Error uploading image:", error)
throw error
} finally {
Expand Down
19 changes: 3 additions & 16 deletions app/whois/ProfileDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions lib/avatarImageConfig.ts
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions lib/processAvatarImage.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer> {
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)
}
}
42 changes: 42 additions & 0 deletions lib/supabaseServer.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
44 changes: 44 additions & 0 deletions lib/uploadAvatar.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading