diff --git a/apps/web/lib/api/screenshots.ts b/apps/web/lib/api/screenshots.ts index 7a597b8..91d5b9d 100644 --- a/apps/web/lib/api/screenshots.ts +++ b/apps/web/lib/api/screenshots.ts @@ -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 } diff --git a/plugins/decky-vault/main.py b/plugins/decky-vault/main.py index 8d926a4..838f4db 100644 --- a/plugins/decky-vault/main.py +++ b/plugins/decky-vault/main.py @@ -605,6 +605,115 @@ exec mangohud "$@" except Exception as e: return {"success": False, "error": str(e), "status": 0} + async def list_screenshots(self, limit: int = 12) -> dict: + """RPC: List recent Steam Deck screenshots from ~/Pictures/Screenshots/. + Returns {screenshots: [{path, name, mtime, size}], error?}. + Steam saves timestamped JPGs in a 'Steam Client' subfolder and keeps + a 'most_recent.jpg' symlink-like copy at the top level.""" + import glob + import time + try: + home = os.path.expanduser("~") + base = os.path.join(home, "Pictures", "Screenshots") + patterns = [ + os.path.join(base, "*.jpg"), + os.path.join(base, "*.png"), + os.path.join(base, "Steam Client", "*.jpg"), + os.path.join(base, "Steam Client", "*.png"), + ] + seen = set() + files = [] + for pat in patterns: + for f in glob.glob(pat): + if not os.path.isfile(f) or f in seen: + continue + # Skip the most_recent.jpg duplicate if a real timestamped + # copy exists — it's just a pointer to the latest one. + if os.path.basename(f) == "most_recent.jpg": + continue + seen.add(f) + try: + files.append({ + "path": f, + "name": os.path.basename(f), + "mtime": os.path.getmtime(f), + "size": os.path.getsize(f), + }) + except OSError: + continue + files.sort(key=lambda x: x["mtime"], reverse=True) + return {"screenshots": files[:limit]} + except Exception as e: + return {"screenshots": [], "error": str(e)} + + async def upload_screenshots(self, entry_id: str, screenshot_paths: list, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict: + """RPC: Upload up to 2 screenshots to a performance entry as multipart/form-data. + Returns {success: bool, uploaded: int, error?: str, status?: int}. + The server enforces the 2-screenshot limit per entry.""" + import urllib.request + import urllib.error + import uuid + + # Hard cap at 2 — matches the website limit + paths = [p for p in screenshot_paths if p][:2] + if not paths: + return {"success": False, "error": "No screenshots selected"} + + try: + url = f"{base_url}/api/performance/{entry_id}/screenshots" + + # Build a multipart/form-data body manually (urllib has no helper) + boundary = "----DeckyVaultBoundary" + uuid.uuid4().hex + encoded = b"" + valid_paths = [] + for p in paths: + if not os.path.exists(p): + continue + valid_paths.append(p) + with open(p, "rb") as fh: + file_bytes = fh.read() + fname = os.path.basename(p) + ext = os.path.splitext(fname)[1].lower() + mime = "image/png" if ext == ".png" else ("image/webp" if ext == ".webp" else "image/jpeg") + encoded += ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="screenshots"; filename="{fname}"\r\n' + f"Content-Type: {mime}\r\n\r\n" + ).encode("utf-8") + encoded += file_bytes + encoded += b"\r\n" + encoded += f"--{boundary}--\r\n".encode("utf-8") + + if not valid_paths: + return {"success": False, "error": "No readable screenshot files"} + + req = urllib.request.Request( + url, + data=encoded, + headers={ + "Content-Type": f"multipart/form-data; boundary={boundary}", + "x-api-key": api_key, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:136.0) Gecko/20100101 Firefox/136.0", + "Accept": "application/json", + }, + method="POST", + ) + context = _get_ssl_context() + with urllib.request.urlopen(req, timeout=30, context=context) as response: + result = json.loads(response.read().decode("utf-8")) + uploaded = len(result.get("data", [])) if isinstance(result.get("data"), list) else 0 + return {"success": True, "uploaded": uploaded} + except urllib.error.HTTPError as e: + try: + err = json.loads(e.read().decode("utf-8")) + return {"success": False, "error": err.get("error", f"Server returned status {e.code}"), "status": e.code} + except Exception: + return {"success": False, "error": f"Server returned status {e.code}", "status": e.code} + except urllib.error.URLError as e: + return {"success": False, "error": f"Network error: {str(e.reason)}", "status": 0} + except Exception as e: + return {"success": False, "error": str(e), "status": 0} + async def test_api_key(self, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict: """RPC: Test if an API key is valid by calling the dedicated verify endpoint. Returns {valid: bool, error: str?, userName?: str, userImage?: str}.""" diff --git a/plugins/decky-vault/src/components/session-form.tsx b/plugins/decky-vault/src/components/session-form.tsx index a2954bb..414bdd8 100644 --- a/plugins/decky-vault/src/components/session-form.tsx +++ b/plugins/decky-vault/src/components/session-form.tsx @@ -5,7 +5,6 @@ import { PanelSectionRow, DropdownItem, TextField, - Field, staticClasses, } from "@decky/ui" import { @@ -13,11 +12,20 @@ import { FaCloudUploadAlt, FaCheck, FaTimes, + FaImages, + FaPlus, + FaTrash, + FaSpinner, } from "react-icons/fa" import type { SessionData } from "../lib/store" import { buildImportPayload } from "../lib/store" import type { PluginSettings } from "../lib/store" -import { exportToFile, uploadToDeckyvault } from "../lib/api" +import { + exportToFile, + uploadToDeckyvault, + listScreenshots, + uploadScreenshots, +} from "../lib/api" interface SessionFormProps { session: SessionData @@ -46,6 +54,29 @@ const FRAME_GEN_OPTIONS = [ { label: "Other", data: "other" }, ] +const MAX_SCREENSHOTS = 2 + +interface ScreenshotFile { + path: string + name: string + mtime: number + size: number +} + +function formatShotTime(mtime: number): string { + const d = new Date(mtime * 1000) + const today = new Date() + const isToday = d.toDateString() === today.toDateString() + const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + return isToday ? `Today ${time}` : `${d.toLocaleDateString()} ${time}` +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + export default function SessionForm({ session, error, @@ -59,6 +90,41 @@ export default function SessionForm({ const [uploadStatus, setUploadStatus] = useState<"idle" | "loading" | "success" | "error">("idle") const [statusMessage, setStatusMessage] = useState("") + // ── Screenshot picker state ────────────────────────────── + const [selectedShots, setSelectedShots] = useState([]) + const [pickerOpen, setPickerOpen] = useState(false) + const [availableShots, setAvailableShots] = useState([]) + const [shotsLoading, setShotsLoading] = useState(false) + const [shotsError, setShotsError] = useState("") + + async function openPicker() { + if (selectedShots.length >= MAX_SCREENSHOTS) return + setPickerOpen(true) + setShotsLoading(true) + setShotsError("") + const result = await listScreenshots(12) + setShotsLoading(false) + if (result.error) { + setShotsError(result.error) + } + // Filter out already-selected paths + setAvailableShots(result.screenshots.filter((s) => !selectedShots.some((sel) => sel.path === s.path))) + } + + function addShot(shot: ScreenshotFile) { + if (selectedShots.length >= MAX_SCREENSHOTS) return + setSelectedShots((prev) => [...prev, shot]) + setAvailableShots((prev) => prev.filter((s) => s.path !== shot.path)) + if (selectedShots.length + 1 >= MAX_SCREENSHOTS) { + setPickerOpen(false) + } + } + + function removeShot(shot: ScreenshotFile) { + setSelectedShots((prev) => prev.filter((s) => s.path !== shot.path)) + setAvailableShots((prev) => [...prev, shot].sort((a, b) => b.mtime - a.mtime)) + } + async function handleExport() { if (!settings) return setError("") @@ -88,7 +154,7 @@ export default function SessionForm({ } setError("") setUploadStatus("loading") - setStatusMessage("") + setStatusMessage("Uploading entry…") const payload = buildImportPayload(session) const result = await uploadToDeckyvault( @@ -97,17 +163,41 @@ export default function SessionForm({ settings.baseUrl, ) - if (result.success) { - setUploadStatus("success") - setStatusMessage(`Uploaded! Entry ID: ${result.data?.id}`) - onAddToRecent(session) - } else { + if (!result.success) { setUploadStatus("error") setStatusMessage(result.error || "Upload failed") if (result.status === 404) { setStatusMessage("This game isn't in DeckyVault yet. Submit it on the website first, or export to file.") } + return } + + const entryId = result.data?.id + + // ── Upload screenshots if any are selected ─────────────── + if (selectedShots.length > 0 && entryId) { + setStatusMessage(`Entry uploaded. Adding ${selectedShots.length} screenshot${selectedShots.length > 1 ? "s" : ""}…`) + const shotResult = await uploadScreenshots( + entryId, + selectedShots.map((s) => s.path), + settings.apiKey, + settings.baseUrl, + ) + if (shotResult.success) { + setUploadStatus("success") + const n = shotResult.uploaded ?? selectedShots.length + setStatusMessage(`Uploaded! Entry + ${n} screenshot${n > 1 ? "s" : ""}`) + } else { + // Entry succeeded but screenshots failed — still a partial success + setUploadStatus("success") + setStatusMessage(`Entry uploaded (ID: ${entryId}). Screenshots failed: ${shotResult.error}`) + } + } else { + setUploadStatus("success") + setStatusMessage(`Uploaded! Entry ID: ${entryId}`) + } + + onAddToRecent(session) } return ( @@ -195,6 +285,129 @@ export default function SessionForm({ + {/* ── Screenshots (max 2) ────────────────────────────────── */} + 0 ? ` (${selectedShots.length}/${MAX_SCREENSHOTS})` : ""}`}> + +
+ Attach up to {MAX_SCREENSHOTS} Steam Deck screenshots (Steam + R1). They upload with your entry. +
+
+ + {/* Selected screenshots */} + {selectedShots.map((shot) => ( + +
+ +
+
+ {shot.name} +
+
+ {formatShotTime(shot.mtime)} · {formatSize(shot.size)} +
+
+ +
+
+ ))} + + {/* Add button (hidden when at max) */} + {selectedShots.length < MAX_SCREENSHOTS && !pickerOpen && ( + + +
+ + Add Screenshot +
+
+
+ )} + + {/* Picker: list of recent Steam screenshots */} + {pickerOpen && ( + <> + +
+ Recent screenshots +
+
+ + {shotsLoading && ( + +
+ Loading… +
+
+ )} + + {shotsError && !shotsLoading && ( + +
+ {shotsError} +
+
+ )} + + {!shotsLoading && !shotsError && availableShots.length === 0 && ( + +
+ No screenshots found. Take one with Steam + R1. +
+
+ )} + + {!shotsLoading && availableShots.map((shot) => ( + + addShot(shot)}> +
+ +
+
+ {formatShotTime(shot.mtime)} +
+
+ {formatSize(shot.size)} +
+
+ +
+
+
+ ))} + + + setPickerOpen(false)}> +
+ + Cancel +
+
+
+ + )} +
+ {/* ── Error display ──────────────────────────────────────── */} {error && ( diff --git a/plugins/decky-vault/src/lib/api.ts b/plugins/decky-vault/src/lib/api.ts index 97f676b..483e9b6 100644 --- a/plugins/decky-vault/src/lib/api.ts +++ b/plugins/decky-vault/src/lib/api.ts @@ -81,6 +81,23 @@ export const uploadToDeckyvault = callable<[ status?: number }>("upload_to_deckyvault") +export const listScreenshots = callable<[limit?: number], { + screenshots: Array<{ path: string; name: string; mtime: number; size: number }> + error?: string +}>("list_screenshots") + +export const uploadScreenshots = callable<[ + entryId: string, + screenshotPaths: string[], + apiKey: string, + baseUrl?: string +], { + success: boolean + uploaded?: number + error?: string + status?: number +}>("upload_screenshots") + export const testApiKey = callable<[apiKey: string, baseUrl?: string], { valid: boolean error?: string