diff --git a/next.config.js b/next.config.js index 54fd1975..422456ed 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,7 @@ -if (process.env.NODE_ENV === "development") { +if ( + process.env.NODE_ENV === "development" && + process.env.STARDEW_APP_LOCAL_ONLY !== "1" +) { const { initOpenNextCloudflareForDev } = require("@opennextjs/cloudflare"); initOpenNextCloudflareForDev(); } @@ -6,6 +9,7 @@ if (process.env.NODE_ENV === "development") { /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + output: "standalone", typescript: { ignoreBuildErrors: true }, eslint: { ignoreDuringBuilds: true }, rewrites: async () => { diff --git a/scripts/node-readlink-compat.cjs b/scripts/node-readlink-compat.cjs new file mode 100644 index 00000000..21f35fd7 --- /dev/null +++ b/scripts/node-readlink-compat.cjs @@ -0,0 +1,57 @@ +const fs = require("node:fs"); + +function shouldNormalizeReadlinkError(error, path) { + if (!error || error.code !== "EISDIR") return false; + + try { + return !fs.lstatSync(path).isSymbolicLink(); + } catch { + return false; + } +} + +function normalizeReadlinkError(error) { + error.code = "EINVAL"; + error.message = error.message.replace("EISDIR", "EINVAL"); + return error; +} + +const originalReadlinkSync = fs.readlinkSync.bind(fs); +fs.readlinkSync = function readlinkSyncCompat(path, options) { + try { + return originalReadlinkSync(path, options); + } catch (error) { + if (shouldNormalizeReadlinkError(error, path)) { + throw normalizeReadlinkError(error); + } + throw error; + } +}; + +const originalReadlink = fs.readlink.bind(fs); +fs.readlink = function readlinkCompat(path, options, callback) { + if (typeof options === "function") { + callback = options; + options = undefined; + } + + return originalReadlink(path, options, (error, result) => { + if (error && shouldNormalizeReadlinkError(error, path)) { + callback(normalizeReadlinkError(error)); + return; + } + callback(error, result); + }); +}; + +const originalPromisesReadlink = fs.promises.readlink.bind(fs.promises); +fs.promises.readlink = async function promisesReadlinkCompat(path, options) { + try { + return await originalPromisesReadlink(path, options); + } catch (error) { + if (shouldNormalizeReadlinkError(error, path)) { + throw normalizeReadlinkError(error); + } + throw error; + } +}; diff --git a/src/components/dialogs/upload-dialog.tsx b/src/components/dialogs/upload-dialog.tsx index e0cb232d..005d9a35 100644 --- a/src/components/dialogs/upload-dialog.tsx +++ b/src/components/dialogs/upload-dialog.tsx @@ -8,6 +8,7 @@ import { } from "@/components/ui/dialog"; import { PlayersContext } from "@/contexts/players-context"; import { parseSaveFile } from "@/lib/file"; +import { readBestSaveFileText } from "@/lib/save-loader"; import { useContext, useState } from "react"; import Dropzone from "react-dropzone"; import { toast } from "sonner"; @@ -21,7 +22,7 @@ interface Props { interface InstructionsDialogProps { open: boolean; setOpen: (open: boolean) => void; - platform: "Mac" | "Windows" | "Linux" | "Switch"; + platform: "Mac" | "Windows" | "Linux" | "Switch" | "PlayStation"; } const InstructionsDialog = ({ @@ -78,6 +79,16 @@ const InstructionsDialog = ({ "We apologize for any inconvenience this may cause.", ], }; + case "PlayStation": + return { + title: "PS4 and PS Vita Save Files", + path: "", + steps: [ + "Export the save with Apollo Save Tool, Save Wizard, VitaShell Open Decrypted, psvpfstools, or an equivalent tool.", + "If the export gives you farm folders, upload the farm-named payload such as Farmer_123456789 or Farmmc_355939291.", + "The app can read plain XML and zlib-compressed PlayStation payloads. SaveGameInfo and pfsSKKey metadata are not complete saves by themselves.", + ], + }; } }; @@ -131,50 +142,36 @@ export const UploadDialog = ({ open, setOpen }: Props) => { const { activePlayer, uploadPlayers } = useContext(PlayersContext); const [instructionsOpen, setInstructionsOpen] = useState(false); const [selectedPlatform, setSelectedPlatform] = useState< - "Mac" | "Windows" | "Linux" | "Switch" + "Mac" | "Windows" | "Linux" | "Switch" | "PlayStation" >("Mac"); - const handleChange = (file: File) => { + const handleChange = (files: File[]) => { setOpen(false); - if (typeof file === "undefined" || !file) return; + if (!files.length) return; - if (file.type !== "") { + if (files.length === 1 && files[0].type !== "") { toast.error("Invalid file type", { description: "Please upload a Stardew Valley save file.", }); return; } - const reader = new FileReader(); - - let uploadPromise; - - reader.onloadstart = () => { - uploadPromise = new Promise((resolve, reject) => { - reader.onload = async function (event) { - try { - const players = parseSaveFile(event.target?.result as string); - await uploadPlayers(players); - resolve("Your save file was successfully uploaded!"); - } catch (err) { - reject(err instanceof Error ? err.message : "Unknown error."); - } - }; - }); - - // Start the loading toast - toast.promise(uploadPromise, { - loading: "Uploading your save file...", - success: (data) => `${data}`, - error: (err) => `There was an error parsing your save file:\n${err}`, - }); - - // Reset the input - uploadPromise = null; - }; + const uploadPromise = (async () => { + const { text } = await readBestSaveFileText(files); + const players = parseSaveFile(text); + await uploadPlayers(players); + return "Your save file was successfully uploaded!"; + })(); - reader.readAsText(file); + toast.promise(uploadPromise, { + loading: "Uploading your save file...", + success: (data) => `${data}`, + error: (err) => + `There was an error parsing your save file:\n${ + err instanceof Error ? err.message : "Unknown error." + }`, + }); }; return ( @@ -187,9 +184,10 @@ export const UploadDialog = ({ open, setOpen }: Props) => { { - handleChange(acceptedFiles[0]); + handleChange(acceptedFiles); }} useFsAccessApi={false} + multiple > {({ getRootProps, getInputProps }) => ( <> @@ -258,6 +256,16 @@ export const UploadDialog = ({ open, setOpen }: Props) => { > Nintendo Switch + diff --git a/src/components/sheets/mobile-nav.tsx b/src/components/sheets/mobile-nav.tsx index 636ce364..6d771f64 100644 --- a/src/components/sheets/mobile-nav.tsx +++ b/src/components/sheets/mobile-nav.tsx @@ -32,6 +32,7 @@ import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent } from "@/components/ui/drawer"; import { fetchJson } from "@/lib/fetch"; import { parseSaveFile } from "@/lib/file"; +import { readBestSaveFileText } from "@/lib/save-loader"; import { toast } from "sonner"; import { ScrollArea } from "../ui/scroll-area"; @@ -66,48 +67,35 @@ export const MobileNav = ({ const handleChange = (e: ChangeEvent) => { e.preventDefault(); - const file = e.target!.files![0]; + const files = e.target.files; + const file = files?.[0]; setIsOpen(false); - if (typeof file === "undefined" || !file) return; + if (!files?.length || typeof file === "undefined" || !file) return; - if (file.type !== "") { + if (files.length === 1 && file.type !== "") { toast.error("Invalid file type", { description: "Please upload a Stardew Valley save file.", }); return; } - const reader = new FileReader(); + const uploadPromise = (async () => { + const { text } = await readBestSaveFileText(files); + const players = parseSaveFile(text); + await uploadPlayers(players); + return "Your save file was successfully uploaded!"; + })(); - let uploadPromise; - - reader.onloadstart = () => { - uploadPromise = new Promise((resolve, reject) => { - reader.onload = async function (event) { - try { - const players = parseSaveFile(event.target?.result as string); - await uploadPlayers(players); - resolve("Your save file was successfully uploaded!"); - } catch (err) { - reject(err instanceof Error ? err.message : "Unknown error."); - } - }; - }); - - // Start the loading toast - toast.promise(uploadPromise, { - loading: "Uploading your save file...", - success: (data) => `${data}`, - error: (err) => `There was an error parsing your save file:\n${err}`, - }); - - // Reset the input - uploadPromise = null; - }; - - reader.readAsText(file); + toast.promise(uploadPromise, { + loading: "Uploading your save file...", + success: (data) => `${data}`, + error: (err) => + `There was an error parsing your save file:\n${ + err instanceof Error ? err.message : "Unknown error." + }`, + }); }; return ( @@ -145,6 +133,7 @@ export const MobileNav = ({ type="file" ref={inputRef} className="hidden" + multiple onChange={(e: ChangeEvent) => handleChange(e) } diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 995cd5b1..d895ae57 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -8,6 +8,7 @@ import { IconAward, IconBook, IconBox, + IconArrowsExchange, IconBrandDiscord, IconBrandGithub, IconBuildingWarehouse, @@ -44,6 +45,7 @@ export const miscNavigation = [ { name: "Bundles", href: "/bundles", icon: IconBox }, { name: "Secret Notes", href: "/notes", icon: IconNote }, { name: "Rarecrows", href: "/rarecrows", icon: IconCarrot }, + { name: "Save Converter", href: "/converter", icon: IconArrowsExchange }, { name: "Account Settings", href: "/account", icon: IconSettings }, ]; diff --git a/src/lib/console-save.ts b/src/lib/console-save.ts new file mode 100644 index 00000000..d46c6298 --- /dev/null +++ b/src/lib/console-save.ts @@ -0,0 +1,99 @@ +export type SavePlatform = "PC" | "Mobile" | "Console"; + +export type SaveInputFormat = + | "xml" + | "decrypted-console-xml" + | "compressed-console-save" + | "ps4-pfs-key" + | "ps4-save-container" + | "unknown"; + +export interface SaveInputValidation { + format: SaveInputFormat; + isXml: boolean; + isLikelyDecryptedConsoleSave: boolean; + isRawConsoleContainer: boolean; + reason: string; +} + +const XML_DECLARATION = " 0) return normalized.slice(xmlIndex); + if (xmlIndex === 0) return normalized; + if (saveGameIndex > 0) return normalized.slice(saveGameIndex); + + return normalized; +} + +export function validateDecryptedConsoleSave(input: string): SaveInputValidation { + const normalized = normalizeSaveXml(input); + const startsWithPfsKey = input.startsWith(PFS_KEY_SIGNATURE); + const startsWithXml = + normalized.startsWith(XML_DECLARATION) || normalized.startsWith(SAVE_GAME_ROOT); + const hasSaveGameRoot = normalized.includes(SAVE_GAME_ROOT); + const hasStandardStardewNamespaces = + normalized.includes("xmlns:xsi=") || normalized.includes("xmlns:p3="); + + if (startsWithPfsKey) { + return { + format: "ps4-pfs-key", + isXml: false, + isLikelyDecryptedConsoleSave: false, + isRawConsoleContainer: true, + reason: + "PS4 pfsSKKey metadata was uploaded. Select the matching exported save folder or the large save payload instead.", + }; + } + + if (!startsWithXml && !hasSaveGameRoot) { + const startsWithControlCharacter = input.charCodeAt(0) <= 0x1f; + return { + format: startsWithControlCharacter ? "ps4-save-container" : "unknown", + isXml: false, + isLikelyDecryptedConsoleSave: false, + isRawConsoleContainer: startsWithControlCharacter, + reason: + "This does not contain visible Stardew Valley XML. If this is a PlayStation save, select the exported farm folder or compressed farm payload inside it.", + }; + } + + return { + format: hasStandardStardewNamespaces ? "decrypted-console-xml" : "xml", + isXml: true, + isLikelyDecryptedConsoleSave: hasStandardStardewNamespaces && hasSaveGameRoot, + isRawConsoleContainer: false, + reason: + "Stardew Valley XML save detected. Decrypted PS4 and PS Vita saves use this same XML structure after extraction.", + }; +} + +export function getTypeAttributePrefix(saveGame: any): "xsi" | "p3" { + if (typeof saveGame?.["@_xmlns:xsi"] !== "undefined") return "xsi"; + if (typeof saveGame?.["@_xmlns:p3"] !== "undefined") return "p3"; + + const locations = saveGame?.locations?.GameLocation; + const locationList = Array.isArray(locations) + ? locations + : locations + ? [locations] + : []; + for (const location of locationList) { + if (typeof location?.["@_xsi:type"] !== "undefined") return "xsi"; + if (typeof location?.["@_p3:type"] !== "undefined") return "p3"; + } + + return "xsi"; +} diff --git a/src/lib/file.ts b/src/lib/file.ts index 9b363af4..dba4dd0c 100644 --- a/src/lib/file.ts +++ b/src/lib/file.ts @@ -15,6 +15,12 @@ import { parseShipping, parseSocial, } from "@/lib/parsers"; +import { + getTypeAttributePrefix, + normalizeSaveXml, + validateDecryptedConsoleSave, +} from "@/lib/console-save"; +import type { SavePlatform } from "@/lib/console-save"; import { GetListOrEmpty, getAllFarmhands } from "@/lib/utils"; import { parseAnimals } from "./parsers/animals"; import { parseNotes } from "./parsers/notes"; @@ -25,11 +31,21 @@ import { parseWalnuts } from "./parsers/walnuts"; const semverSatisfies = require("semver/functions/satisfies"); const semverCoerce = require("semver/functions/coerce"); -export function parseSaveFile(xml: string) { +interface ParseSaveFileOptions { + platformHint?: SavePlatform; +} + +export function parseSaveFile(xml: string, options: ParseSaveFileOptions = {}) { const parser = new XMLParser({ ignoreAttributes: false }); let saveFile: any = null; + const validation = validateDecryptedConsoleSave(xml); + + if (validation.isRawConsoleContainer || !validation.isXml) { + throw new Error(validation.reason); + } + try { - saveFile = parser.parse(xml); + saveFile = parser.parse(normalizeSaveXml(xml)); } catch (e) { if (e instanceof TypeError) { throw new Error( @@ -39,6 +55,12 @@ export function parseSaveFile(xml: string) { } try { + if (!saveFile.SaveGame) { + throw new Error( + "Invalid file uploaded. Couldn't find the Stardew Valley SaveGame root.", + ); + } + let versionString: string = ""; if (!saveFile.SaveGame.gameVersion) { versionString = "1.4.5"; // assume 1.4.5 if gameVersion is not present @@ -63,11 +85,11 @@ export function parseSaveFile(xml: string) { players = getAllFarmhands(saveFile.SaveGame); // find the prefix to use for attributes (xsi for pc, p3 for mobile) - const prefix = - typeof saveFile.SaveGame["@_xmlns:xsi"] === "undefined" ? "p3" : "xsi"; + const prefix = getTypeAttributePrefix(saveFile.SaveGame); - // Determine platform based on prefix - const platform = prefix === "xsi" ? "PC" : "Mobile"; + // Decrypted console saves are normal Stardew XML; callers can set a hint + // when the source is known because the XML itself is usually indistinguishable. + const platform = options.platformHint ?? (prefix === "xsi" ? "PC" : "Mobile"); const parsedBundles = parseBundles( saveFile.SaveGame.bundleData, diff --git a/src/lib/parsers/general.ts b/src/lib/parsers/general.ts index 217272b8..c6c5e4ff 100644 --- a/src/lib/parsers/general.ts +++ b/src/lib/parsers/general.ts @@ -1,4 +1,5 @@ import { GetListOrEmpty, GetStatValue, isPlayerFormatUpdated } from "../utils"; +import type { SavePlatform } from "@/lib/console-save"; function msToTime(time: number): string { const hrs = Math.floor(time / 3600000); @@ -266,14 +267,14 @@ export interface GeneralRet { jojaMembership?: JojaRet; achievements?: AchievementsRet; islandUpgrades?: IslandUpgradesRet; - platform?: "PC" | "Mobile" | "Console"; + platform?: SavePlatform; } export function parseGeneral( player: any, whichFarm: string, gameVersion: string, - platform?: "PC" | "Mobile", + platform?: SavePlatform, ): GeneralRet { try { const playerFormatUpdated = isPlayerFormatUpdated(player); diff --git a/src/lib/save-loader.ts b/src/lib/save-loader.ts new file mode 100644 index 00000000..458c68e8 --- /dev/null +++ b/src/lib/save-loader.ts @@ -0,0 +1,106 @@ +import { normalizeSaveXml } from "@/lib/console-save"; + +const METADATA_NAMES = new Set([ + "SaveGameInfo", + "startup_preferences", + "param.sfo", + "icon0.png", + "keystone", +]); + +function getFilePath(file: File): string { + const maybePath = file as File & { webkitRelativePath?: string; path?: string }; + return maybePath.webkitRelativePath || maybePath.path || file.name; +} + +function rankSaveCandidate(file: File): number { + const path = getFilePath(file).replace(/\\/g, "/"); + const name = file.name; + let rank = 0; + + if (path.includes("/sce_sys/") || path.startsWith("sce_sys/")) rank += 1000; + if (METADATA_NAMES.has(name)) rank += 1000; + if (/\.bin$/i.test(name) || name.includes("pfsSKKey")) rank += 1000; + if (file.size < 1024) rank += 100; + + if (!METADATA_NAMES.has(name) && !path.includes("/sce_sys/") && !/\.bin$/i.test(name)) { + rank -= 100; + } + + return rank; +} + +export function getSaveCandidates(files: File[] | FileList): File[] { + return Array.from(files).sort((a, b) => { + const rankDiff = rankSaveCandidate(a) - rankSaveCandidate(b); + if (rankDiff !== 0) return rankDiff; + return b.size - a.size; + }); +} + +async function inflateWithNativeStream( + buffer: ArrayBuffer, + format: CompressionFormat, +): Promise { + if (typeof DecompressionStream === "undefined") { + throw new Error("This browser does not support native save decompression."); + } + + const stream = new Blob([buffer]) + .stream() + .pipeThrough(new DecompressionStream(format)); + return await new Response(stream).text(); +} + +function decodeUtf8(buffer: ArrayBuffer): string { + return new TextDecoder("utf-8").decode(buffer); +} + +function assertSaveGamePayload(text: string): void { + if (!normalizeSaveXml(text).includes(" { + const buffer = await file.arrayBuffer(); + + for (const format of ["deflate", "deflate-raw", "gzip"] as CompressionFormat[]) { + try { + const text = await inflateWithNativeStream(buffer, format); + assertSaveGamePayload(text); + return text; + } catch { + // Try the next compression format, then fall back to plain UTF-8 XML. + } + } + + const text = decodeUtf8(buffer); + assertSaveGamePayload(text); + return text; +} + +export async function readBestSaveFileText(files: File[] | FileList): Promise<{ + file: File; + text: string; +}> { + const candidates = getSaveCandidates(files); + let lastError: unknown = null; + + for (const file of candidates) { + try { + return { + file, + text: await readSaveFileText(file), + }; + } catch (error) { + lastError = error; + } + } + + throw lastError instanceof Error + ? lastError + : new Error("No save payload was found in the selected file or folder."); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 92048dcd..045d084f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -16,7 +16,7 @@ export function cn(...inputs: ClassValue[]) { */ export function getAllFarmhands(saveGame: any): any[] { let farmhands: any[] = []; - const version: string = saveGame.gameVersion.toString(); + const version: string = saveGame.gameVersion?.toString() ?? "1.4.5"; if (saveGame.player) { farmhands.push(saveGame.player); diff --git a/src/pages/api/me.ts b/src/pages/api/me.ts index f2156c24..e061ca4e 100644 --- a/src/pages/api/me.ts +++ b/src/pages/api/me.ts @@ -6,6 +6,10 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getUID } from "./saves"; async function get(req: NextApiRequest, res: NextApiResponse) { + if (process.env.STARDEW_APP_LOCAL_ONLY === "1") { + return res.json(undefined); + } + return withDb(async (db) => { const uid = await getUID(req, res, db); if (!uid) return res.status(401).end(); diff --git a/src/pages/api/saves/index.ts b/src/pages/api/saves/index.ts index 595831f3..5fa10799 100644 --- a/src/pages/api/saves/index.ts +++ b/src/pages/api/saves/index.ts @@ -5,10 +5,76 @@ import { getServerCookieDomain } from "@/lib/cookies"; import { getCookie, setCookie } from "cookies-next"; import crypto from "crypto"; import { and, eq } from "drizzle-orm"; +import { mkdir, readFile, writeFile } from "fs/promises"; import { NextApiRequest, NextApiResponse } from "next"; +import path from "path"; type Data = Record; +type LocalSaveStore = Map; + +let localStoreLoaded = false; + +function isLocalOnlyMode() { + return process.env.STARDEW_APP_LOCAL_ONLY === "1"; +} + +function getLocalStore(): LocalSaveStore { + const globalStore = globalThis as typeof globalThis & { + __stardewAppLocalSaveStore?: LocalSaveStore; + }; + + if (!globalStore.__stardewAppLocalSaveStore) { + globalStore.__stardewAppLocalSaveStore = new Map(); + } + + return globalStore.__stardewAppLocalSaveStore; +} + +function getLocalStorePath(): string | undefined { + return process.env.STARDEW_APP_LOCAL_STORE; +} + +function setLocalDebugHeaders(res: NextApiResponse): void { + const storePath = getLocalStorePath(); + if (storePath) { + res.setHeader("X-Stardew-Local-Store", storePath); + } +} + +async function loadLocalStore(): Promise { + const store = getLocalStore(); + if (localStoreLoaded) return store; + + localStoreLoaded = true; + const storePath = getLocalStorePath(); + if (!storePath) return store; + + try { + const raw = (await readFile(storePath, "utf8")).replace(/^\uFEFF/, ""); + const data = JSON.parse(raw) as Record; + store.clear(); + for (const [uid, players] of Object.entries(data)) { + store.set(uid, players); + } + } catch (error: any) { + if (error?.code !== "ENOENT") { + console.warn(`Failed to load local save store: ${error.message}`); + } + } + + return store; +} + +async function persistLocalStore(store: LocalSaveStore): Promise { + const storePath = getLocalStorePath(); + if (!storePath) return; + + const data = Object.fromEntries(store.entries()); + await mkdir(path.dirname(storePath), { recursive: true }); + await writeFile(storePath, JSON.stringify(data, null, 2), "utf8"); +} + function parseRequestBody(body: unknown): T { if (typeof body === "string") { if (!body) { @@ -91,6 +157,20 @@ export async function getUID( return uid; } +function getLocalUID(req: NextApiRequest, res: NextApiResponse): string { + let uid = getCookie("uid", { req, res }); + if (uid && typeof uid === "string") return uid; + + uid = crypto.randomBytes(16).toString("hex"); + setCookie("uid", uid, { + req, + res, + maxAge: 60 * 60 * 24 * 365, + domain: getServerCookieDomain(req), + }); + return uid; +} + // magic functions dreamt up by me, i think they're secure lol, i use them a lot - Leah export const createToken = (userId: string, key: string, validFor: number) => { const expires = Math.floor(new Date().getTime() / 1000 + validFor); @@ -126,6 +206,17 @@ async function getPlayersByUid(db: Db, uid: string) { } async function get(req: NextApiRequest, res: NextApiResponse) { + if (isLocalOnlyMode()) { + const uid = getLocalUID(req, res); + const store = await loadLocalStore(); + setLocalDebugHeaders(res); + res.setHeader( + "Cache-Control", + "no-store, no-cache, must-revalidate, max-age=0", + ); + return res.json(store.get(uid) ?? []); + } + return withDb(async (db) => { res.setHeader( "Cache-Control", @@ -140,6 +231,29 @@ async function get(req: NextApiRequest, res: NextApiResponse) { } async function post(req: NextApiRequest, res: NextApiResponse) { + if (isLocalOnlyMode()) { + const uid = getLocalUID(req, res); + const players = parseRequestBody(req.body); + const store = await loadLocalStore(); + setLocalDebugHeaders(res); + const existing = store.get(uid) ?? []; + const byId = new Map(existing.map((player) => [player._id, player])); + + for (const player of players) { + if (!player._id) continue; + byId.set(player._id, player); + } + + const savedPlayers = Array.from(byId.values()); + store.set(uid, savedPlayers); + await persistLocalStore(store); + res.setHeader( + "Cache-Control", + "no-store, no-cache, must-revalidate, max-age=0", + ); + return res.status(200).json(savedPlayers); + } + return withDb(async (db) => { res.setHeader( "Cache-Control", @@ -178,6 +292,31 @@ async function post(req: NextApiRequest, res: NextApiResponse) { } async function _delete(req: NextApiRequest, res: NextApiResponse) { + if (isLocalOnlyMode()) { + const uid = getLocalUID(req, res); + const body = req.body + ? parseRequestBody<{ type?: string; _id?: string }>(req.body) + : undefined; + const store = await loadLocalStore(); + setLocalDebugHeaders(res); + + if (body?.type === "player" && body._id) { + store.set( + uid, + (store.get(uid) ?? []).filter((player) => player._id !== body._id), + ); + } else { + store.delete(uid); + } + + await persistLocalStore(store); + res.setHeader( + "Cache-Control", + "no-store, no-cache, must-revalidate, max-age=0", + ); + return res.status(200).json(store.get(uid) ?? []); + } + return withDb(async (db) => { const uid = await getUID(req, res, db); const body = req.body diff --git a/src/pages/converter.tsx b/src/pages/converter.tsx new file mode 100644 index 00000000..3fa517aa --- /dev/null +++ b/src/pages/converter.tsx @@ -0,0 +1,279 @@ +import Head from "next/head"; +import { ChangeEvent, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { normalizeSaveXml } from "@/lib/console-save"; +import { readBestSaveFileText } from "@/lib/save-loader"; + +interface LoadedSave { + fileName: string; + xml: string; + farmerName: string; + farmName: string; + saveId: string; + gameVersion: string; +} + +function getTextContent(document: Document, tagName: string): string { + return document.getElementsByTagName(tagName).item(0)?.textContent?.trim() || ""; +} + +function getLoadedSave(fileName: string, text: string): LoadedSave { + const xml = normalizeSaveXml(text); + const document = new DOMParser().parseFromString(xml, "text/xml"); + const parseError = document.getElementsByTagName("parsererror").item(0); + + if (parseError) { + throw new Error("The selected payload could not be parsed as Stardew XML."); + } + + return { + fileName, + xml, + farmerName: getTextContent(document, "name") || "Unknown farmer", + farmName: getTextContent(document, "farmName") || "Unknown farm", + saveId: getTextContent(document, "uniqueIDForThisGame"), + gameVersion: getTextContent(document, "gameVersion") || "Unknown version", + }; +} + +function getBaseName(fileName: string): string { + return fileName.replace(/\.[^.]+$/, "") || "stardew-save"; +} + +function downloadBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +async function deflateXml(xml: string): Promise { + if (typeof CompressionStream === "undefined") { + throw new Error("This browser does not support save compression."); + } + + const stream = new Blob([xml], { type: "text/xml" }) + .stream() + .pipeThrough(new CompressionStream("deflate")); + return await new Response(stream).blob(); +} + +export default function Converter() { + const [loadedSave, setLoadedSave] = useState(null); + const [isExporting, setIsExporting] = useState(false); + const inputRef = useRef(null); + const folderInputRef = useRef(null); + + const pcFileName = useMemo(() => { + if (!loadedSave) return "stardew-save"; + if (loadedSave.farmerName !== "Unknown farmer" && loadedSave.saveId) { + return `${loadedSave.farmerName}_${loadedSave.saveId}`; + } + return `${getBaseName(loadedSave.fileName)}.xml`; + }, [loadedSave]); + + const consoleFileName = useMemo(() => { + if (!loadedSave) return "stardew-save"; + if (loadedSave.saveId) return `${loadedSave.farmerName}_${loadedSave.saveId}`; + return getBaseName(loadedSave.fileName); + }, [loadedSave]); + + const loadFiles = async (files: FileList | null) => { + if (!files?.length) return; + + const promise = (async () => { + const { file, text } = await readBestSaveFileText(files); + setLoadedSave(getLoadedSave(file.name, text)); + })(); + + toast.promise(promise, { + loading: "Reading save payload", + success: "Save payload loaded", + error: (err) => + `Error loading save: ${ + err instanceof Error ? err.message : "Unknown error." + }`, + }); + }; + + const exportPcXml = () => { + if (!loadedSave) return; + downloadBlob( + new Blob([loadedSave.xml], { type: "text/xml" }), + pcFileName, + ); + }; + + const exportCompressedPayload = async () => { + if (!loadedSave) return; + setIsExporting(true); + try { + const blob = await deflateXml(loadedSave.xml); + downloadBlob(blob, consoleFileName); + toast.success("Compressed console payload exported"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "The console payload could not be exported.", + ); + } finally { + setIsExporting(false); + } + }; + + const folderInputProps = { + webkitdirectory: "", + directory: "", + } as Record; + + return ( + <> + + Save Converter | stardew.app + + +
+
+
+

Save Converter

+

+ Load a PC XML save, PS4 payload, PS Vita payload, or an exported + save folder. Export either plain PC XML or a compressed console + payload for reinjection with your console save tool. +

+
+ +
+ + + Open Save Payload + + Use this for a PC save file, SAVEDATA00 payload, or any single + decrypted Stardew save payload. + + + + + ) => + loadFiles(event.target.files) + } + /> + + + + + + Open Save Folder + + Use this for exported PlayStation folders that include farm + payloads, SaveGameInfo, and metadata files. + + + + + ) => + loadFiles(event.target.files) + } + /> + + +
+ + + + Loaded Save + + The converter changes only the Stardew save payload. PS4 sealed + containers and account encryption still need your normal save + manager. + + + + {loadedSave ? ( + <> +
+
+

+ Source file +

+

+ {loadedSave.fileName} +

+
+
+

+ Game version +

+

{loadedSave.gameVersion}

+
+
+

+ Farmer +

+

{loadedSave.farmerName}

+
+
+

+ Farm +

+

{loadedSave.farmName}

+
+
+
+ + +
+ + ) : ( +

+ No save loaded yet. +

+ )} +
+
+
+
+ + ); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index be26a1e2..bda38716 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,6 +2,7 @@ import Head from "next/head"; import Image from "next/image"; import { parseSaveFile } from "@/lib/file"; +import { readBestSaveFileText } from "@/lib/save-loader"; import { ChangeEvent, useRef, useState } from "react"; import { toast } from "sonner"; @@ -16,45 +17,65 @@ export default function Home() { const [loginOpen, setLoginOpen] = useState(false); const inputRef = useRef(null); + const folderInputRef = useRef(null); const handleChange = (e: ChangeEvent) => { e.preventDefault(); - const file = e.target!.files![0]; + const files = e.target.files; + const file = files?.[0]; - if (typeof file === "undefined" || !file) return; + if (!files?.length || typeof file === "undefined" || !file) return; - if (file.type !== "") { + if (files.length === 1 && file.type !== "") { toast.error("Invalid File Type", { description: "Please upload a Stardew Valley save file.", }); return; } - const reader = new FileReader(); + const uploadPromise = (async () => { + const { text } = await readBestSaveFileText(files); + const players = parseSaveFile(text); + await uploadPlayers(players); + })(); + + toast.promise(uploadPromise, { + loading: "Uploading Save File", + success: "Uploaded Save File", + error: (err) => + `Error parsing file: ${ + err instanceof Error ? err.message : "Unknown error." + }`, + }); + }; - reader.onloadstart = () => { - toast.loading("Uploading Save File", { - description: "Please wait while we upload your save file.", - }); - }; + const handleFolderChange = async (e: ChangeEvent) => { + e.preventDefault(); + if (!e.target.files?.length) return; - reader.onload = async function (event) { - try { - const players = parseSaveFile(event.target?.result as string); + toast.promise( + (async () => { + const { text } = await readBestSaveFileText(e.target.files!); + const players = parseSaveFile(text); await uploadPlayers(players); - toast.success("Uploaded Save File", { - description: "Your save file has been uploaded successfully", - }); - } catch (err) { - toast.error("Error Parsing File", { - description: err instanceof Error ? err.message : "Unknown error.", - }); - } - }; - reader.readAsText(file); + })(), + { + loading: "Uploading PlayStation save folder...", + success: "Uploaded Save File", + error: (err) => + `Error parsing file: ${ + err instanceof Error ? err.message : "Unknown error." + }`, + }, + ); }; + const uploadFolderInputProps = { + webkitdirectory: "", + directory: "", + } as Record; + return ( <> @@ -138,8 +159,39 @@ export default function Home() { type="file" ref={inputRef} className="hidden" + multiple onChange={(e: ChangeEvent) => handleChange(e)} /> + ) => + handleFolderChange(e) + } + /> + +
{ + folderInputRef.current?.click(); + }} + > + Upload a PlayStation save folder +
+

Upload PS4 / Vita folder

+

+ Select an exported folder with farm payloads and SaveGameInfo. +

+