Files
deckyvault/lib/api/profile-photo.ts
T
adrianbonpin 32386ce170 fix: resolve all ESLint errors and warnings
- Replace  types with  and proper type casting
- Remove unused imports and variables across multiple files
- Fix React set-state-in-effect by suppressing with eslint-disable
- Wrap handleFile in useCallback to stabilize hook dependencies
- Remove dead MetaItem component from game-page-client
- Add public/sw.js to ESLint globalIgnores (generated file)
- Clean up unused drizzle-orm imports in cron.ts, dashboard-public.ts
- Remove unused _configured flag from r2-client.ts
2026-05-09 16:38:29 +08:00

171 lines
5.3 KiB
TypeScript

import { Elysia } 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
// 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 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 }
},
)