feat: add profile photo upload/delete API routes with R2 storage

This commit is contained in:
2026-05-09 12:54:12 +08:00
parent 6009366fb4
commit 20f1d33751
4 changed files with 185 additions and 0 deletions
+3
View File
@@ -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)
+1
View File
@@ -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"
+176
View File
@@ -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 }
},
)
+5
View File
@@ -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",
} }
], ],
}, },