The plugin can now attach up to 2 Steam Deck screenshots (Steam + R1) to a performance entry, uploaded together in one Submit tap. Backend: - Screenshot upload/delete endpoints now accept API key auth via requireRoleWithApiKeyFallback (was session-only), so the plugin's x-api-key header works. The 2-screenshot limit was already enforced server-side (MAX_SCREENSHOTS_PER_ENTRY = 2). Plugin Python (main.py): - list_screenshots(): scans ~/Pictures/Screenshots/ + Steam Client/ subfolder, skips the most_recent.jpg duplicate, returns recent shots - upload_screenshots(): hard-caps at 2, builds multipart/form-data with urllib, posts to /api/performance/:id/screenshots Plugin UI (session-form.tsx): - New Screenshots section (max 2) with a picker listing recent Steam screenshots (time + size), selected shots as removable rows - Submit flow: upload entry → get entry ID → upload selected screenshots → combined success message (partial-success if screenshots fail)
273 lines
8.7 KiB
TypeScript
273 lines
8.7 KiB
TypeScript
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 { requireAuthWithApiKeyFallback } from "@/lib/auth/api-key-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
|
|
|
|
/** Auth that accepts either a session cookie or an x-api-key header,
|
|
* then enforces a role. Used so the Decky plugin (API key) and the web
|
|
* app (session) can both upload screenshots. */
|
|
async function requireRoleWithApiKeyFallback(
|
|
headers: Headers,
|
|
roles: string[],
|
|
) {
|
|
const guard = await requireAuthWithApiKeyFallback(headers)
|
|
if (!guard.ok) return guard
|
|
const userRole = guard.user.role ?? "user"
|
|
if (!roles.includes(userRole)) {
|
|
return { ok: false as const, error: "Forbidden", status: 403 }
|
|
}
|
|
return guard
|
|
}
|
|
|
|
export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
|
|
|
|
// ── Upload screenshots ─────────────────────────────────────────
|
|
.post(
|
|
"/:id/screenshots",
|
|
async ({ params, request, set }) => {
|
|
const guard = await requireRoleWithApiKeyFallback(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 }) => {
|
|
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 requireRoleWithApiKeyFallback(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() }),
|
|
},
|
|
) |