diff --git a/lib/api/app.ts b/lib/api/app.ts index 3a4e126..b94c1db 100644 --- a/lib/api/app.ts +++ b/lib/api/app.ts @@ -34,6 +34,7 @@ import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy" import { dashboardRoutes } from "@/lib/api/dashboard" import { savedFilterRoutes } from "@/lib/api/saved-filters" import { cronRoutes } from "@/lib/api/cron" +import { profilePhotoRoutes } from "@/lib/api/profile-photo" const betterAuth = new Elysia({ name: "better-auth" }) .mount(auth.handler) @@ -71,6 +72,8 @@ export const app = new Elysia({ prefix: "/api" }) .use(healthRoutes) // Users .use(userRoutes) + // Profile photos + .use(profilePhotoRoutes) // Games + Versions .use(gamesRoutes) .use(gameVersionsRoutes) diff --git a/lib/api/index.ts b/lib/api/index.ts index 82f8ff4..4db5f6a 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -23,3 +23,4 @@ export { communitySuggestionRoutes } from "./community-suggestions" export { savedFilterRoutes } from "./saved-filters" export { dashboardRoutes } from "./dashboard" export { cronRoutes } from "./cron" +export { profilePhotoRoutes } from "./profile-photo" diff --git a/lib/api/profile-photo.ts b/lib/api/profile-photo.ts new file mode 100644 index 0000000..050910b --- /dev/null +++ b/lib/api/profile-photo.ts @@ -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 = { + "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 } + }, + ) \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index b06e9f3..25e6f54 100644 --- a/next.config.ts +++ b/next.config.ts @@ -39,6 +39,11 @@ const nextConfig: NextConfig = { { protocol: 'https', hostname: 'cdn.discordapp.com' + }, + // Cloudflare R2 + { + protocol: "https", + hostname: "**.r2.dev", } ], },