From 3cbb3538da02dcdf223235f3b7a07a959a200ac8 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sat, 9 May 2026 22:40:05 +0800 Subject: [PATCH] feat: add screenshot upload/list/delete API routes --- lib/api/app.ts | 2 + lib/api/screenshots.ts | 257 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 lib/api/screenshots.ts diff --git a/lib/api/app.ts b/lib/api/app.ts index ecacb18..bd5a9cb 100644 --- a/lib/api/app.ts +++ b/lib/api/app.ts @@ -36,6 +36,7 @@ import { dashboardPublicRoutes } from "@/lib/api/dashboard-public" import { savedFilterRoutes } from "@/lib/api/saved-filters" import { cronRoutes } from "@/lib/api/cron" import { profilePhotoRoutes } from "@/lib/api/profile-photo" +import { screenshotRoutes } from "@/lib/api/screenshots" const betterAuth = new Elysia({ name: "better-auth" }) .mount(auth.handler) @@ -87,6 +88,7 @@ export const app = new Elysia({ prefix: "/api" }) .use(performanceRoutes) .use(performanceVerifyRoutes) .use(performanceSubmitRoutes) + .use(screenshotRoutes) .use(reportRoutes) .use(adminReportRoutes) .use(adminPerformanceRoutes) diff --git a/lib/api/screenshots.ts b/lib/api/screenshots.ts new file mode 100644 index 0000000..df1c273 --- /dev/null +++ b/lib/api/screenshots.ts @@ -0,0 +1,257 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { performanceEntries, entryScreenshots, storageObjects } from "@/lib/db/schema" +import { eq, sql } from "drizzle-orm" +import { requireRole } from "@/lib/auth/guard" +import { uploadObject, deleteObject, getR2PublicUrl, isR2Configured } from "@/lib/storage" +import { processScreenshot, isAllowedMimeType } from "@/lib/image-processing" + +const MAX_SCREENSHOTS_PER_ENTRY = 2 +const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB raw + +export const screenshotRoutes = new Elysia({ prefix: "/performance" }) + + // ── Upload screenshots ───────────────────────────────────────── + .post( + "/:id/screenshots", + async ({ params, request, set }) => { + const guard = await requireRole(request.headers, ["user", "contributor", "admin"]) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + if (!isR2Configured()) { + set.status = 503 + return { error: "Storage not configured" } + } + + // Verify entry exists and user is owner or admin + const [entry] = await db + .select({ + id: performanceEntries.id, + userId: performanceEntries.userId, + }) + .from(performanceEntries) + .where(eq(performanceEntries.id, params.id)) + .limit(1) + + if (!entry) { + set.status = 404 + return { error: "Performance entry not found" } + } + + if (entry.userId !== guard.user.id && guard.user.role !== "admin") { + set.status = 403 + return { error: "Not authorized to upload screenshots for this entry" } + } + + // Check existing screenshot count + const [countResult] = await db + .select({ count: sql`count(*)::int` }) + .from(entryScreenshots) + .where(eq(entryScreenshots.entryId, params.id)) + + if ((countResult?.count ?? 0) >= MAX_SCREENSHOTS_PER_ENTRY) { + set.status = 400 + return { error: `Maximum ${MAX_SCREENSHOTS_PER_ENTRY} screenshots per entry` } + } + + // Parse multipart form data + const formData = await request.formData() + const files = formData.getAll("screenshots") + + if (!files || files.length === 0) { + set.status = 400 + return { error: "No files provided" } + } + + // Remaining slots + const remaining = MAX_SCREENSHOTS_PER_ENTRY - (countResult?.count ?? 0) + const toProcess = files.slice(0, remaining).filter((f): f is File => f instanceof File) + + if (toProcess.length === 0) { + set.status = 400 + return { error: "No valid files provided or entry already has maximum screenshots" } + } + + // Get existing max order index + const [maxOrder] = await db + .select({ maxIndex: sql`coalesce(max(${entryScreenshots.orderIndex}), -1)` }) + .from(entryScreenshots) + .where(eq(entryScreenshots.entryId, params.id)) + + const startIndex = (maxOrder?.maxIndex ?? -1) + 1 + + const results = [] + + for (let i = 0; i < toProcess.length; i++) { + const file = toProcess[i] + + // Validate MIME type + if (!isAllowedMimeType(file.type)) { + continue // Skip invalid types + } + + // Validate file size + if (file.size > MAX_UPLOAD_SIZE) { + continue // Skip oversized files + } + + try { + const arrayBuffer = await file.arrayBuffer() + const inputBuffer = Buffer.from(arrayBuffer) + + // Process and compress + const processed = await processScreenshot(inputBuffer, file.type) + + // Upload to R2 + const storageKey = `screenshots/${params.id}/${crypto.randomUUID()}.jpg` + const publicUrl = await uploadObject( + storageKey, + processed.buffer, + processed.mimeType, + { userId: guard.user.id, entityType: "entry_screenshot", entityId: params.id }, + ) + + // Track in storage_objects + await db.insert(storageObjects).values({ + key: storageKey, + bucket: process.env.R2_BUCKET_NAME ?? "deckyvault", + size: processed.size, + mimeType: processed.mimeType, + entityType: "entry_screenshot", + entityId: params.id, + uploadedBy: guard.user.id, + }) + + // Create screenshot record + const [screenshot] = await db + .insert(entryScreenshots) + .values({ + entryId: params.id, + storageKey, + orderIndex: startIndex + i, + mimeType: processed.mimeType, + width: processed.width, + height: processed.height, + originalName: file.name || null, + }) + .returning() + + results.push({ + id: screenshot.id, + entryId: screenshot.entryId, + url: publicUrl, + orderIndex: screenshot.orderIndex, + width: processed.width, + height: processed.height, + }) + } catch (err) { + console.error(`Failed to process screenshot ${i}:`, err) + } + } + + if (results.length === 0) { + set.status = 500 + return { error: "Failed to process any screenshots" } + } + + return { data: results } + }, + { + params: t.Object({ id: t.String() }), + }, + ) + + // ── List screenshots ──────────────────────────────────────────── + .get( + "/:id/screenshots", + async ({ params, set }) => { + const screenshots = await db + .select({ + id: entryScreenshots.id, + entryId: entryScreenshots.entryId, + storageKey: entryScreenshots.storageKey, + orderIndex: entryScreenshots.orderIndex, + mimeType: entryScreenshots.mimeType, + width: entryScreenshots.width, + height: entryScreenshots.height, + originalName: entryScreenshots.originalName, + createdAt: entryScreenshots.createdAt, + }) + .from(entryScreenshots) + .where(eq(entryScreenshots.entryId, params.id)) + .orderBy(entryScreenshots.orderIndex) + + const publicUrl = getR2PublicUrl() + + return { + data: screenshots.map((s) => ({ + ...s, + url: `${publicUrl}/${s.storageKey}`, + })), + } + }, + { + params: t.Object({ id: t.String() }), + }, + ) + + // ── Delete screenshot ──────────────────────────────────────────── + .delete( + "/:id/screenshots/:sid", + async ({ params, request, set }) => { + const guard = await requireRole(request.headers, ["user", "contributor", "admin"]) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + // Find the screenshot + const [screenshot] = await db + .select() + .from(entryScreenshots) + .where(eq(entryScreenshots.id, params.sid)) + .limit(1) + + if (!screenshot) { + set.status = 404 + return { error: "Screenshot not found" } + } + + // Verify ownership or admin + const [entry] = await db + .select({ userId: performanceEntries.userId }) + .from(performanceEntries) + .where(eq(performanceEntries.id, screenshot.entryId)) + .limit(1) + + if (entry && entry.userId !== guard.user.id && guard.user.role !== "admin") { + set.status = 403 + return { error: "Not authorized to delete this screenshot" } + } + + // Delete from R2 + try { + await deleteObject(screenshot.storageKey) + } catch { + console.warn(`Failed to delete screenshot from R2: ${screenshot.storageKey}`) + } + + // Delete storage_objects record + await db + .delete(storageObjects) + .where(eq(storageObjects.key, screenshot.storageKey)) + + // Delete screenshot record + await db + .delete(entryScreenshots) + .where(eq(entryScreenshots.id, params.sid)) + + return { success: true } + }, + { + params: t.Object({ id: t.String(), sid: t.String() }), + }, + ) \ No newline at end of file