feat: add screenshot upload/list/delete API routes
This commit is contained in:
@@ -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<number>`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<number>`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() }),
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user