Files
deckyvault/apps/web/lib/api/performance-submit.ts
T
adrianbonpin cd72b7a948 refactor: convert to bun workspaces monorepo
- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
2026-06-28 05:20:28 +08:00

473 lines
16 KiB
TypeScript

import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index"
import {
performanceEntries,
gameVersions,
hardware,
gamePlatformSupport,
entryScreenshots,
storageObjects,
} from "@/lib/db/schema"
import type { GameSettingCategory } from "@/lib/db/schema"
import { eq, and, sql } from "drizzle-orm"
import { requireRole } from "@/lib/auth/guard"
import { recalculatePlayability } from "./playability"
import { uploadObject, deleteObject, isR2Configured } from "@/lib/storage/r2-client"
import { processScreenshot, isAllowedMimeType, validateMagicBytes } from "@/lib/image-processing"
const MAX_SCREENSHOTS_PER_ENTRY = 2
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB
export const performanceSubmitRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
.get(
"/autocomplete",
async ({ query }) => {
const field = query.field
if (field !== "protonVersion" && field !== "osVersion") {
return { data: [] }
}
const results = await db
.select({ value: performanceEntries[field] })
.from(performanceEntries)
.where(sql`${performanceEntries[field]} IS NOT NULL`)
.groupBy(performanceEntries[field])
.orderBy(sql`count(*) DESC`)
.limit(20)
return {
data: results
.map((r) => r.value)
.filter((v): v is string => v !== null),
}
},
{
query: t.Object({
field: t.Union([
t.Literal("protonVersion"),
t.Literal("osVersion"),
]),
}),
},
)
.get(
"/hardware",
async () => {
const devices = await db
.select({
slug: hardware.slug,
name: hardware.name,
deviceType: hardware.deviceType,
wattHours: hardware.wattHours,
tdpMax: hardware.tdpMax,
})
.from(hardware)
.orderBy(hardware.sortOrder)
return { data: devices }
},
)
.post(
"/submit",
async ({ request, set }) => {
const guard = await requireRole(request.headers, [
"user",
"contributor",
"admin",
])
if (!guard.ok) {
set.status = guard.status
return { error: guard.error }
}
// Parse multipart form data
let formData: FormData
try {
formData = await request.formData()
} catch {
set.status = 400
return { error: "Invalid multipart/form-data" }
}
// Extract and parse the JSON payload field
const payloadField = formData.get("payload")
if (!payloadField || typeof payloadField !== "string") {
set.status = 400
return { error: "Missing or invalid payload field" }
}
let payload: Record<string, unknown>
try {
payload = JSON.parse(payloadField)
} catch {
set.status = 400
return { error: "Invalid JSON in payload field" }
}
// Extract fields from payload
const versionId = payload.versionId as string
const hardwareSlug = payload.hardwareSlug as string
const fpsAvg = payload.fpsAvg as number
const fpsOnePercentLow = (payload.fpsOnePercentLow as number | null | undefined) ?? null
const fpsLow = (payload.fpsLow as number | null | undefined) ?? null
const fpsHigh = (payload.fpsHigh as number | null | undefined) ?? null
const protonVersion = (payload.protonVersion as string | null | undefined) ?? null
const osVersion = (payload.osVersion as string | null | undefined) ?? null
const VALID_UPSCALER_TYPES = ["none", "fsr", "dlss", "xess", "lsfg", "other"] as const
const VALID_FRAME_GEN_METHODS = ["none", "fsr_fg", "dlss_fg", "lsfg", "other"] as const
const VALID_ANTICHEAT_STATUSES = ["none", "supported", "unsupported", "unknown"] as const
type UpscalerType = (typeof VALID_UPSCALER_TYPES)[number]
type FrameGenMethod = (typeof VALID_FRAME_GEN_METHODS)[number]
type AntiCheatStatus = (typeof VALID_ANTICHEAT_STATUSES)[number]
const rawUpscalerType = payload.upscalerType as string | undefined
const upscalerType: UpscalerType = rawUpscalerType && VALID_UPSCALER_TYPES.includes(rawUpscalerType as UpscalerType) ? (rawUpscalerType as UpscalerType) : "none"
const upscalerVersion = (payload.upscalerVersion as string | null | undefined) ?? null
const customSystem = (payload.customSystem as boolean | undefined) ?? false
const rawFrameGenMethod = payload.frameGenMethod as string | undefined
const frameGenMethod: FrameGenMethod = rawFrameGenMethod && VALID_FRAME_GEN_METHODS.includes(rawFrameGenMethod as FrameGenMethod) ? (rawFrameGenMethod as FrameGenMethod) : "none"
const loadTimeSsd = (payload.loadTimeSsd as number | null | undefined) ?? null
const loadTimeSd = (payload.loadTimeSd as number | null | undefined) ?? null
const tdpWatts = (payload.tdpWatts as number | null | undefined) ?? null
const youtubeVideoId = (payload.youtubeVideoId as string | null | undefined) ?? null
const launchOptions = (payload.launchOptions as string | null | undefined) ?? null
const settingsJson = (payload.settingsJson as GameSettingCategory[] | null | undefined) ?? null
const userNotes = (payload.userNotes as string | null | undefined) ?? null
const antiCheatRelevant = (payload.antiCheatRelevant as boolean | undefined) ?? false
const antiCheatName = (payload.antiCheatName as string | null | undefined) ?? null
const rawAntiCheatStatus = payload.antiCheatStatus as string | undefined
const antiCheatStatus: AntiCheatStatus = rawAntiCheatStatus && VALID_ANTICHEAT_STATUSES.includes(rawAntiCheatStatus as AntiCheatStatus) ? (rawAntiCheatStatus as AntiCheatStatus) : "unknown"
// Validate required fields
if (!versionId || !hardwareSlug || fpsAvg === undefined || fpsAvg === null) {
set.status = 400
return { error: "Missing required fields: versionId, hardwareSlug, fpsAvg" }
}
// ── Validation: fpsAvg bounds ───────────────────────────────
if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 500) {
set.status = 400
return { error: "fpsAvg must be between 1 and 500" }
}
// ── Validation: optional FPS bounds ─────────────────────────
if (fpsLow !== null && (fpsLow < 0 || fpsLow > 500)) {
set.status = 400
return { error: "fpsLow must be between 0 and 500" }
}
if (fpsHigh !== null && (fpsHigh < 0 || fpsHigh > 500)) {
set.status = 400
return { error: "fpsHigh must be between 0 and 500" }
}
if (fpsOnePercentLow !== null && (fpsOnePercentLow < 0 || fpsOnePercentLow > 500)) {
set.status = 400
return { error: "fpsOnePercentLow must be between 0 and 500" }
}
// ── Validation: settingsJson size limits ────────────────────
if (settingsJson) {
if (!Array.isArray(settingsJson)) {
set.status = 400
return { error: "settingsJson must be an array" }
}
if (settingsJson.length > 20) {
set.status = 400
return { error: "Maximum 20 settings categories allowed" }
}
for (const cat of settingsJson) {
if (cat.settings && Array.isArray(cat.settings) && cat.settings.length > 50) {
set.status = 400
return { error: `Maximum 50 settings per category (exceeded in "${cat.category}")` }
}
}
}
// ── Validation: userNotes length ────────────────────────────
if (userNotes && typeof userNotes === "string" && userNotes.length > 5000) {
set.status = 400
return { error: "userNotes must be 5000 characters or less" }
}
// Verify the game version exists
const [version] = await db
.select({ id: gameVersions.id, gameId: gameVersions.gameId })
.from(gameVersions)
.where(eq(gameVersions.id, versionId))
.limit(1)
if (!version) {
set.status = 404
return { error: "Game version not found" }
}
// Verify hardware exists
const [device] = await db
.select({ slug: hardware.slug })
.from(hardware)
.where(eq(hardware.slug, hardwareSlug))
.limit(1)
if (!device) {
set.status = 404
return { error: "Hardware not found" }
}
// Validate YouTube video ID format (11 alphanumeric + dash/underscore)
if (youtubeVideoId) {
const ytId = youtubeVideoId.trim()
if (!/^[a-zA-Z0-9_-]{11}$/.test(ytId)) {
set.status = 400
return { error: "Invalid YouTube video ID format (must be 11 characters)" }
}
}
// Validate TDP
if (tdpWatts !== null && tdpWatts <= 0) {
set.status = 400
return { error: "TDP must be greater than 0" }
}
// ── Process screenshots ──────────────────────────────────────────
const screenshotFiles = formData.getAll("screenshots").filter((f): f is File => f instanceof File)
if (screenshotFiles.length > MAX_SCREENSHOTS_PER_ENTRY) {
set.status = 400
return { error: `Maximum ${MAX_SCREENSHOTS_PER_ENTRY} screenshots allowed` }
}
// Process all screenshots with sharp BEFORE any DB writes
const processedScreenshots: Array<{
buffer: Buffer
width: number
height: number
mimeType: string
size: number
originalName: string | null
}> = []
for (const file of screenshotFiles) {
if (file.size > MAX_UPLOAD_SIZE) {
set.status = 400
return { error: `Screenshot "${file.name}" exceeds 10 MB limit` }
}
if (!isAllowedMimeType(file.type)) {
set.status = 400
return { error: `Screenshot "${file.name}" has unsupported MIME type: ${file.type}` }
}
const arrayBuffer = await file.arrayBuffer()
const rawBuffer = Buffer.from(arrayBuffer)
if (!validateMagicBytes(rawBuffer, file.type)) {
set.status = 400
return { error: `Screenshot "${file.name}" content does not match declared type` }
}
try {
const processed = await processScreenshot(rawBuffer, file.type)
processedScreenshots.push({
...processed,
originalName: file.name || null,
})
} catch (err) {
set.status = 400
return { error: `Failed to process screenshot "${file.name}": ${err instanceof Error ? err.message : "Unknown error"}` }
}
}
// ── Create performance entry ────────────────────────────────────
const [entry] = await db
.insert(performanceEntries)
.values({
versionId,
hardwareSlug,
userId: guard.user.id,
fpsAvg,
fpsOnePercentLow,
fpsLow,
fpsHigh,
protonVersion,
osVersion,
upscalerType,
upscalerVersion,
customSystem,
frameGenMethod,
loadTimeSsd,
loadTimeSd,
tdpWatts,
youtubeVideoId: youtubeVideoId ? youtubeVideoId.trim() : null,
launchOptions,
settingsJson,
userNotes,
})
.returning()
// ── Upload screenshots to R2 ─────────────────────────────────────
const uploadedKeys: string[] = []
const uploadedStorageIds: string[] = []
const screenshotResults: Array<{
id: string
storageKey: string
url: string
width: number
height: number
mimeType: string
size: number
originalName: string | null
orderIndex: number
}> = []
let uploadFailed = false
let uploadError = ""
for (let i = 0; i < processedScreenshots.length; i++) {
const shot = processedScreenshots[i]
const r2Key = `screenshots/${entry.id}/${crypto.randomUUID()}.jpg`
try {
if (!isR2Configured()) {
throw new Error("R2 storage is not configured")
}
const url = await uploadObject(r2Key, shot.buffer, shot.mimeType, {
entryId: entry.id,
orderIndex: String(i),
})
uploadedKeys.push(r2Key)
// Insert into storageObjects
const [storageObj] = await db
.insert(storageObjects)
.values({
key: r2Key,
bucket: "deckyvault",
size: shot.size,
mimeType: shot.mimeType,
entityType: "entry_screenshot",
entityId: entry.id,
uploadedBy: guard.user.id,
})
.returning()
uploadedStorageIds.push(storageObj.id)
// Insert into entryScreenshots
const [screenshotRow] = await db
.insert(entryScreenshots)
.values({
entryId: entry.id,
storageKey: r2Key,
orderIndex: i,
mimeType: shot.mimeType,
width: shot.width,
height: shot.height,
originalName: shot.originalName,
})
.returning()
screenshotResults.push({
id: screenshotRow.id,
storageKey: r2Key,
url,
width: shot.width,
height: shot.height,
mimeType: shot.mimeType,
size: shot.size,
originalName: shot.originalName,
orderIndex: i,
})
} catch (err) {
uploadFailed = true
uploadError = err instanceof Error ? err.message : "Upload failed"
break
}
}
// Roll back if any upload failed
if (uploadFailed) {
// Delete uploaded R2 objects
for (const key of uploadedKeys) {
try {
await deleteObject(key)
} catch {
// Best-effort cleanup
}
}
// Delete storageObjects rows
for (const id of uploadedStorageIds) {
try {
await db.delete(storageObjects).where(eq(storageObjects.id, id))
} catch {
// Best-effort cleanup
}
}
// Delete entry screenshots (should cascade, but be explicit)
try {
await db.delete(entryScreenshots).where(eq(entryScreenshots.entryId, entry.id))
} catch {
// Best-effort cleanup
}
// Delete the performance entry
try {
await db.delete(performanceEntries).where(eq(performanceEntries.id, entry.id))
} catch {
// Best-effort cleanup
}
set.status = 500
return { error: `Screenshot upload failed: ${uploadError}` }
}
// ── Update / create gamePlatformSupport ──────────────────────────
const [existingSupport] = await db
.select()
.from(gamePlatformSupport)
.where(
and(
eq(gamePlatformSupport.gameId, version.gameId),
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
),
)
.limit(1)
if (existingSupport) {
await db
.update(gamePlatformSupport)
.set({
antiCheatRelevant: antiCheatRelevant ?? existingSupport.antiCheatRelevant,
antiCheatName: antiCheatRelevant
? (antiCheatName ?? existingSupport.antiCheatName)
: null,
antiCheatStatus: antiCheatStatus ?? existingSupport.antiCheatStatus,
updatedAt: new Date(),
})
.where(eq(gamePlatformSupport.id, existingSupport.id))
} else {
await db.insert(gamePlatformSupport).values({
gameId: version.gameId,
hardwareSlug,
isSupported: true,
protonStatus: "unknown",
antiCheatRelevant,
antiCheatName: antiCheatRelevant ? antiCheatName : null,
antiCheatStatus,
playabilityStatus: "unknown",
})
}
// Recalculate playability for this game (fire and forget)
recalculatePlayability(version.gameId).catch((err) =>
console.error("Failed to recalculate playability:", err),
)
set.status = 201
return {
id: entry.id,
createdAt: entry.createdAt.toISOString(),
screenshots: screenshotResults,
}
},
)