feat: add profile photo upload/delete API routes with R2 storage
This commit is contained in:
@@ -34,6 +34,7 @@ import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy"
|
|||||||
import { dashboardRoutes } from "@/lib/api/dashboard"
|
import { dashboardRoutes } from "@/lib/api/dashboard"
|
||||||
import { savedFilterRoutes } from "@/lib/api/saved-filters"
|
import { savedFilterRoutes } from "@/lib/api/saved-filters"
|
||||||
import { cronRoutes } from "@/lib/api/cron"
|
import { cronRoutes } from "@/lib/api/cron"
|
||||||
|
import { profilePhotoRoutes } from "@/lib/api/profile-photo"
|
||||||
|
|
||||||
const betterAuth = new Elysia({ name: "better-auth" })
|
const betterAuth = new Elysia({ name: "better-auth" })
|
||||||
.mount(auth.handler)
|
.mount(auth.handler)
|
||||||
@@ -71,6 +72,8 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
.use(healthRoutes)
|
.use(healthRoutes)
|
||||||
// Users
|
// Users
|
||||||
.use(userRoutes)
|
.use(userRoutes)
|
||||||
|
// Profile photos
|
||||||
|
.use(profilePhotoRoutes)
|
||||||
// Games + Versions
|
// Games + Versions
|
||||||
.use(gamesRoutes)
|
.use(gamesRoutes)
|
||||||
.use(gameVersionsRoutes)
|
.use(gameVersionsRoutes)
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ export { communitySuggestionRoutes } from "./community-suggestions"
|
|||||||
export { savedFilterRoutes } from "./saved-filters"
|
export { savedFilterRoutes } from "./saved-filters"
|
||||||
export { dashboardRoutes } from "./dashboard"
|
export { dashboardRoutes } from "./dashboard"
|
||||||
export { cronRoutes } from "./cron"
|
export { cronRoutes } from "./cron"
|
||||||
|
export { profilePhotoRoutes } from "./profile-photo"
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { Elysia, t } from "elysia"
|
||||||
|
import { db } from "@/lib/db/index"
|
||||||
|
import { user } from "@/lib/db/schema"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { requireAuth } from "@/lib/auth/guard"
|
||||||
|
import {
|
||||||
|
uploadObject,
|
||||||
|
deleteObject,
|
||||||
|
isR2Configured,
|
||||||
|
getR2PublicUrl,
|
||||||
|
isR2Url,
|
||||||
|
} from "@/lib/storage"
|
||||||
|
import { storageObjects } from "@/lib/db/schema"
|
||||||
|
|
||||||
|
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
|
const AVATAR_SIZE = 256
|
||||||
|
|
||||||
|
// Magic byte signatures for file type validation
|
||||||
|
const MAGIC_BYTES: Record<string, number[]> = {
|
||||||
|
"image/jpeg": [0xff, 0xd8, 0xff],
|
||||||
|
"image/png": [0x89, 0x50, 0x4e, 0x47],
|
||||||
|
"image/webp": [0x52, 0x49, 0x46, 0x46], // RIFF header (WEBP container)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMagicBytes(buffer: Buffer, declaredMime: string): boolean {
|
||||||
|
const expected = MAGIC_BYTES[declaredMime]
|
||||||
|
if (!expected) return false
|
||||||
|
if (buffer.length < expected.length) return false
|
||||||
|
return expected.every((byte, i) => buffer[i] === byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profilePhotoRoutes = new Elysia({ prefix: "/user" })
|
||||||
|
|
||||||
|
// ── Upload Profile Photo ──────────────────────────────────────────
|
||||||
|
.post(
|
||||||
|
"/profile-photo",
|
||||||
|
async ({ request, set }) => {
|
||||||
|
const guard = await requireAuth(request.headers)
|
||||||
|
if (!guard.ok) {
|
||||||
|
set.status = guard.status
|
||||||
|
return { error: guard.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isR2Configured()) {
|
||||||
|
set.status = 503
|
||||||
|
return { error: "Storage not configured" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form data
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get("photo")
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "No file provided" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate MIME type
|
||||||
|
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: `Invalid file type. Allowed: ${ALLOWED_MIME_TYPES.join(", ")}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: `File too large. Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file buffer and validate magic bytes
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const buffer = Buffer.from(arrayBuffer)
|
||||||
|
|
||||||
|
if (!validateMagicBytes(buffer, file.type)) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "File content does not match declared type" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique key
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const key = `avatars/${guard.user.id}-${timestamp}.webp`
|
||||||
|
|
||||||
|
// Upload to R2 (store original; client-side resize handled later or store as-is)
|
||||||
|
// For MVP: store the original file as-is with its original MIME type
|
||||||
|
const ext = file.type === "image/png" ? "png" : file.type === "image/webp" ? "webp" : "jpg"
|
||||||
|
const actualKey = `avatars/${guard.user.id}-${timestamp}.${ext}`
|
||||||
|
const publicUrl = await uploadObject(actualKey, buffer, file.type, {
|
||||||
|
userId: guard.user.id,
|
||||||
|
entityType: "avatar",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track in storage_objects
|
||||||
|
await db.insert(storageObjects).values({
|
||||||
|
key: actualKey,
|
||||||
|
bucket: process.env.R2_BUCKET_NAME ?? "deckyvault",
|
||||||
|
size: buffer.length,
|
||||||
|
mimeType: file.type,
|
||||||
|
entityType: "avatar",
|
||||||
|
entityId: guard.user.id,
|
||||||
|
uploadedBy: guard.user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if user had a previous custom avatar and delete it
|
||||||
|
const [currentUser] = await db
|
||||||
|
.select({ image: user.image })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, guard.user.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (currentUser?.image && isR2Url(currentUser.image)) {
|
||||||
|
// Extract the key from the URL (everything after R2_PUBLIC_URL/)
|
||||||
|
const oldKey = currentUser.image.replace(`${getR2PublicUrl()}/`, "")
|
||||||
|
try {
|
||||||
|
await deleteObject(oldKey)
|
||||||
|
} catch {
|
||||||
|
// Log but don't block — daily cron will clean up orphaned objects
|
||||||
|
console.warn(`Failed to delete old avatar: ${oldKey}`)
|
||||||
|
}
|
||||||
|
// Remove old tracking record
|
||||||
|
await db
|
||||||
|
.delete(storageObjects)
|
||||||
|
.where(eq(storageObjects.key, oldKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user.image
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({ image: publicUrl, updatedAt: new Date() })
|
||||||
|
.where(eq(user.id, guard.user.id))
|
||||||
|
|
||||||
|
return { url: publicUrl }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Delete Profile Photo ──────────────────────────────────────────
|
||||||
|
.delete(
|
||||||
|
"/profile-photo",
|
||||||
|
async ({ request, set }) => {
|
||||||
|
const guard = await requireAuth(request.headers)
|
||||||
|
if (!guard.ok) {
|
||||||
|
set.status = guard.status
|
||||||
|
return { error: guard.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [currentUser] = await db
|
||||||
|
.select({ image: user.image })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, guard.user.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!currentUser?.image || !isR2Url(currentUser.image)) {
|
||||||
|
return { success: true, message: "No custom photo to delete" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from R2
|
||||||
|
const oldKey = currentUser.image.replace(`${getR2PublicUrl()}/`, "")
|
||||||
|
try {
|
||||||
|
await deleteObject(oldKey)
|
||||||
|
} catch {
|
||||||
|
console.warn(`Failed to delete avatar from R2: ${oldKey}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove tracking record
|
||||||
|
await db
|
||||||
|
.delete(storageObjects)
|
||||||
|
.where(eq(storageObjects.key, oldKey))
|
||||||
|
|
||||||
|
// Clear user.image
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({ image: null, updatedAt: new Date() })
|
||||||
|
.where(eq(user.id, guard.user.id))
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -39,6 +39,11 @@ const nextConfig: NextConfig = {
|
|||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'cdn.discordapp.com'
|
hostname: 'cdn.discordapp.com'
|
||||||
|
},
|
||||||
|
// Cloudflare R2
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**.r2.dev",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user