feat(plugin): attach Steam Deck screenshots to performance submissions

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)
This commit is contained in:
2026-06-29 00:10:01 +08:00
parent 841cbfe0c6
commit b416f49e3d
4 changed files with 366 additions and 11 deletions
+19 -3
View File
@@ -2,20 +2,36 @@ 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 { 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 requireRole(request.headers, ["user", "contributor", "admin"])
const guard = await requireRoleWithApiKeyFallback(request.headers, ["user", "contributor", "admin"])
if (!guard.ok) {
set.status = guard.status
return { error: guard.error }
@@ -202,7 +218,7 @@ export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { t
.delete(
"/:id/screenshots/:sid",
async ({ params, request, set }) => {
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
const guard = await requireRoleWithApiKeyFallback(request.headers, ["user", "contributor", "admin"])
if (!guard.ok) {
set.status = guard.status
return { error: guard.error }