diff --git a/app/downloads/BrowserCaptureDialog.jsx b/app/downloads/BrowserCaptureDialog.jsx index dcea937..915ddb8 100644 --- a/app/downloads/BrowserCaptureDialog.jsx +++ b/app/downloads/BrowserCaptureDialog.jsx @@ -28,10 +28,10 @@ import { FocusScope, Text, View } from '@ps5-react/core'; close(); return true; }, children: /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 50, alignItems: "center", justifyContent: "center", backgroundColor: "#000000f2" }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { width: 1380, gap: 30, borderRadius: 18, borderWidth: 2, borderColor: "#2a2a2f", backgroundColor: "#17171a", padding: 48 }, children: [ - /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 36, color: "#f2f2f3", lineHeight: 48 }, children: t2("capture.title") }), + /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 36, color: "#f2f2f3", lineHeight: 48 }, children: t2(job2.captureMode === "link" ? "capture.link.title" : "capture.title") }), /* @__PURE__ */ (0, jsx)(Text, { style: { width: 1260, fontFamily: "Inter-Regular", fontSize: 24, color: "#a3a3ab", numberOfLines: 2, ellipsizeMode: "tail", lineHeight: 36 }, children: job2.title }), /* @__PURE__ */ (0, jsx)(ShimmerText, { textStyle: INSTRUCTION, background: colors.surface, children: t2(`capture.${job2.captureState}`) }), - /* @__PURE__ */ (0, jsx)(Text, { style: { width: 1260, fontFamily: "Inter-Regular", fontSize: 24, color: "#a3a3ab", numberOfLines: 3, ellipsizeMode: "tail", lineHeight: 36 }, children: t2("capture.detail") }), + /* @__PURE__ */ (0, jsx)(Text, { style: { width: 1260, fontFamily: "Inter-Regular", fontSize: 24, color: "#a3a3ab", numberOfLines: 3, ellipsizeMode: "tail", lineHeight: 36 }, children: t2(job2.captureMode === "link" ? "capture.link.detail" : "capture.detail") }), /* @__PURE__ */ (0, jsx)(Button2, { label: t2("download.enterLink"), variant: "outline", onPress: () => { downloadQueue().cancelBrowserCapture(job2.id, { manual: true }); } }), diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 0ae5f81..f2be218 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -450,5 +450,7 @@ "capture.preparing": "Preparing the file verification\u2026", "capture.capturing": "Complete the verification, then wait", "capture.validating": "Checking the captured file\u2026", - "capture.detail": "Don't press Download or any ad: the link is captured by itself. Then return here and the download starts automatically. Capture lasts up to 3 minutes." + "capture.detail": "Don't press Download or any ad: the link is captured by itself. Then return here and the download starts automatically. Capture lasts up to 3 minutes.", + "capture.link.title": "Download verification", + "capture.link.detail": "Pass the host's check in the browser: the link is captured by itself. If the browser starts downloading the file, cancel it and come back here. Capture lasts up to 3 minutes." } diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index bc3b328..fcfc8f7 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -12,6 +12,8 @@ "browse.viewGame": "Vedi gioco", "capture.capturing": "Completa la verifica, poi aspetta", "capture.detail": "Non premere Download né le pubblicità: il link viene catturato da solo. Poi torna qui e il download parte in automatico. La cattura dura fino a 3 minuti.", + "capture.link.title": "Verifica del download", + "capture.link.detail": "Supera il controllo dell'host nel browser: il link viene catturato da solo. Se il browser inizia a scaricare il file, annullalo e torna qui. La cattura dura fino a 3 minuti.", "capture.preparing": "Preparazione della verifica del file…", "capture.title": "Verifica Vikingfile", "capture.validating": "Controllo del file acquisito…", diff --git a/app/state/browser-links.js b/app/state/browser-links.js new file mode 100644 index 0000000..3072522 --- /dev/null +++ b/app/state/browser-links.js @@ -0,0 +1,71 @@ +// ../pub-game-store/native/state/browser-links.js + +import { webURL } from './download-sources.js'; + + // A host whose check only the console browser can pass: the app arms a capture on the page's + // own URL and takes the file link out of the browser once it appears. The link is the game's + // own file when its name carries the title id, or when it sits on a host known to serve files. + var LINK_PREFIX = "https://"; + var FILE_HOSTS = [ + "akirabox.com", "akirabox.to", "buzzheavier.com", "datanodes.co", "datanodes.to", + "datavaults.co", "fileditchfiles.st", "filekeeper.net", "mediafire.com", "rootz.so", + "vikingfile.com", "vik1ngfile.site", "bhs.io.cloud.ovh.net", "r2.cloudflarestorage.com" + ]; + var FILE_EXTENSIONS = /\.(?:7z|rar|zip|pkg|exfat|ffpkg|ffpfsc|ffpfs|bin|iso|img)(?=[?#&/]|$)/i; + var CATALOG_SLACK = 2 ** 30; + var plainLink = (url) => url.replace(/&/gi, "&").replace(/�*38;/g, "&"); + function captureAnchor(titleId) { + const anchor = String(titleId || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); + return anchor.length >= 4 && anchor.length <= 32 ? anchor : ""; + } + function knownFileHost(host) { + return FILE_HOSTS.some((domain) => host === domain || host.endsWith(`.${domain}`)); + } + function matchingLink(url, anchor) { + try { + const parsed = webURL(url); + if (!url.startsWith("https://") || parsed.origin !== `https://${parsed.host}` || url.includes("#") || + !parsed.host.includes(".") || !FILE_EXTENSIONS.test(url)) + return false; + const name = parsed.path.split("?")[0].split("/").pop() || ""; + if (name && FILE_EXTENSIONS.test(name) && anchor && name.toLowerCase().includes(anchor.toLowerCase())) return true; + return knownFileHost(parsed.host); + } catch { + return false; + } + } + async function rangeProbe(http, url, signal) { + for (let redirects = 0; redirects <= 5; redirects++) { + const response = await http.request(url, { + method: "GET", + headers: { Range: "bytes=0-0" }, + signal, + maxBytes: 8192, + followRedirects: false + }); + if (![301, 302, 303, 307, 308].includes(response.status)) return response; + const location2 = response.headers?.location; + if (!location2 || redirects === 5) throw new Error("Captured file exceeded its redirect limit."); + url = location2.startsWith("/") && !location2.startsWith("//") ? webURL(url).origin + location2 : location2; + if (!url.startsWith("https://")) throw new Error("Captured file redirected to an unsupported download host."); + } + } + async function validateCapturedLink(http, url, metadata, signal) { + const link = plainLink(url); + if (!matchingLink(link, metadata.anchor)) + throw new Error("Captured link does not name this game's file. Enter the final file link."); + const response = await rangeProbe(http, link, signal); + const ranged = response.status === 206; + const range = /^bytes 0-0\/(\d+)$/i.exec(response.headers?.["content-range"] || ""); + const length = Number(response.headers?.["content-length"]); + const bytes = Number(range?.[1]) || length; + if (!response.ok || !Number.isInteger(length) || length <= 0 || ranged && length !== 1 || + /html|json|xml|text\//i.test(response.headers?.["content-type"] || "") || + ranged && (await response.text()).length !== 1 || !Number.isSafeInteger(bytes) || bytes <= 0) + throw new Error("Captured file failed validation or expired. Reopen the page and retry."); + if (metadata.sizeBytes && bytes + CATALOG_SLACK < metadata.sizeBytes) + throw new Error("Captured file is smaller than the selected release. Reopen the page and retry."); + return { url: link, expectedBytes: bytes }; + } + +export { LINK_PREFIX, captureAnchor, matchingLink, validateCapturedLink }; diff --git a/app/state/download-queue.js b/app/state/download-queue.js index 3386f49..9e3b112 100644 --- a/app/state/download-queue.js +++ b/app/state/download-queue.js @@ -2,11 +2,14 @@ // reconstructed from the esbuild bundle in eboot.bin // MODIFIED: an Archive.org refusal fails over or fails as a dead link instead of asking for the browser. // MODIFIED: an archive the catalog's password doesn't open is tried with the known release passwords. +// MODIFIED: any provider that needs the console browser (LinkVault's mirrors, Akirabox, Datanodes) +// has its file link captured from the browser, like Vikingfile's. import { INCOMPLETE_PART, STALE_PARTIAL, cause } from '../downloads/errors.js'; import { t } from '../i18n/core.js'; import { archivePassword } from './archive-password.js'; import { BrowserRequired, archiveRefusal, cancellation, createSourceResolver, sourceKind, webURL } from './download-sources.js'; +import { LINK_PREFIX, captureAnchor, validateCapturedLink } from './browser-links.js'; import { PKG_FIRMWARE_ERROR, fakePackagesInstall, isPs5Title } from './firmware.js'; import { readJSON } from './read-json.js'; import { mountedImages, shadowRequest, within2 } from './shadowmount.js'; @@ -1036,6 +1039,7 @@ import { VIKING_PREFIX, capturedVikingFile, describeVikingCapture, linkPageMetad } delete job2.captureState; delete job2.captureError; + delete job2.captureMode; } function continueJob(job2, url, verifiedOptions) { if (job2.state !== "awaiting-browser" || active2?.job === job2) return; @@ -1099,6 +1103,52 @@ import { VIKING_PREFIX, capturedVikingFile, describeVikingCapture, linkPageMetad pump(); } } + /** The same capture for a host whose check only the browser can pass: the vault's mirrors, + * Akirabox, Datanodes. The file link appears on the page, or when its download starts, and the + * browser's memory is read for it: the human never types it. */ + async function captureBrowserLink(job2, source2, session) { + const current = () => captures.get(job2.id) === session && jobs.includes(job2) && job2.state === "awaiting-browser"; + let handingOff = false; + try { + const anchor = captureAnchor(job2.titleId); + console.log("[browser-capture] watching any file link carrying", anchor); + session.task = browserCapture.capture({ prefix: LINK_PREFIX, suffix: `/${anchor}`, timeoutSeconds: 180 }); + job2.captureState = "capturing"; + change(); + if (!linking?.openURL(webURL(job2.browserUrl).url)) throw new Error("The browser could not open. Retry or enter the final file link."); + const result = await session.task.done; + if (!current()) return; + job2.captureState = "validating"; + change(); + console.log("[browser-capture] link captured; validating one-byte range"); + const validated = await validateCapturedLink(http, result.url, { anchor, sizeBytes: source2.sizeBytes || null }, session.cancel.signal); + if (!current()) return; + // The file's real length replaces the one the catalog could not know, as for Vikingfile. + source2.sizeBytes = validated.expectedBytes; + if (!job2.parts) job2.total = validated.expectedBytes; + notifications.show(t("downloads.notice.captured"), job2.title); + console.log("[browser-capture] file validated; handing off to queue"); + handingOff = true; + continueJob(job2, validated.url, { + url: validated.url, + expectedBytes: validated.expectedBytes, + sha256: source2.sha256 || void 0, + rejectHtml: true + }); + console.log("[browser-capture] handoff finished; state:", job2.state); + } catch (error2) { + if (!current() && !(handingOff && jobs.includes(job2) && job2.state === "queued" && active2?.job !== job2)) return; + console.log("[browser-capture] failed:", error2.message); + session.cancel.cancel(); + session.task?.cancel(); + captures.delete(job2.id); + job2.state = "awaiting-browser"; + job2.captureState = "failed"; + job2.captureError = error2.message; + change(); + pump(); + } + } return { snapshot, subscribe(listener) { @@ -1240,15 +1290,20 @@ import { VIKING_PREFIX, capturedVikingFile, describeVikingCapture, linkPageMetad const job2 = find(id); if (job2.state !== "awaiting-browser") return; const source2 = job2.parts ? job2.currentSource : job2.release; - if (browserCapture && (sourceKind(source2) === "vikingfile" || vikingLinkPage(source2))) { + const viking = sourceKind(source2) === "vikingfile" || vikingLinkPage(source2); + const anchor = viking ? "" : captureAnchor(job2.titleId); + if (browserCapture && (viking || anchor)) { if (captures.has(id)) return; - if (captures.size || active2) throw new Error("Wait for the current queue task before opening Vikingfile, so its captured link can start immediately."); + if (captures.size || active2) throw new Error("Wait for the current queue task before opening the download page, so its captured link can start immediately."); const session = { cancel: cancellation(), task: null }; captures.set(id, session); job2.captureState = "preparing"; delete job2.captureError; + if (viking) delete job2.captureMode; + else job2.captureMode = "link"; change(); - void captureBrowser(job2, source2, session); + if (viking) void captureBrowser(job2, source2, session); + else void captureBrowserLink(job2, source2, session); return; } if (!linking?.openURL(webURL(job2.browserUrl).url)) throw new Error("The browser could not open. Open this source on another device."); diff --git a/ps5-react/CHANGELOG.md b/ps5-react/CHANGELOG.md index 804e0b3..f29fa15 100644 --- a/ps5-react/CHANGELOG.md +++ b/ps5-react/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased — one capture for every browser-only provider + +- Browser capture also takes the file link from the browser for the hosts whose check only the + console browser passes (LinkVault's mirror pages, Akirabox, Datanodes, Filekeeper), not just + Vikingfile: `prefix: 'https://'` with the game's title id as the suffix returns the file link that + carries that id in its own name, or, on a host known to serve files, one that names its archive + freely. The payload's name check reads the last path segment only, so an advertisement that hides + the real address in its query is not the file, and the app's one-byte range probe still decides + whether the captured URL is the game's own file. + ## Unreleased — Vikingfile capture without the Download button - Browser capture finds the verified Vikingfile link as the page holds it once the verification diff --git a/ps5-react/docs/NATIVE-API.md b/ps5-react/docs/NATIVE-API.md index c535562..00f1dd7 100644 --- a/ps5-react/docs/NATIVE-API.md +++ b/ps5-react/docs/NATIVE-API.md @@ -613,6 +613,8 @@ const task = BrowserCapture.capture({ suffix: '/Selected file.pkg', timeoutSeconds: 180, }); +// Anchor mode: every host whose check only the console browser passes. +const other = BrowserCapture.capture({prefix: 'https://', suffix: '/PPSA01285', timeoutSeconds: 180}); const off = task.subscribe(({state}) => console.log(state)); try { const {url} = await task.done; /* Validate with HTTP before transferring. */ } finally { off(); } @@ -620,16 +622,25 @@ finally { off(); } PS5-only, one capture at a time. Sends bundled `browser-capture.elf` to the localhost ELF loader on port 9021. The desktop returns a failed task explaining -that a PS5 is required. The only permitted prefix is the exact Vikingfile HTTPS -download host; suffix is `/` plus a filename of at most 160 UTF-8 bytes without -controls, slashes, backslashes or `..`. The deadline is an integer from 1 to -180 seconds. Snapshots have `id`, `state`, `error` and a matching `url` on +that a PS5 is required. Two prefixes are permitted. The Vikingfile HTTPS download +host (and its `vik1ngfile.site` alias) captures that page's own download route; +its suffix is `/` plus the filename the page will use, and an empty filename +accepts any safe one. The scheme alone, `https://`, is anchor mode: the suffix is +`/` plus the game's title id of 4 to 160 bytes, and the capture returns the file +link that carries that id in its own name, or, on a host known to serve files, +one that names its archive freely. The last path segment alone is the name, and +only the app's own probe decides whether a captured URL really is the file. +Either way the suffix is at most 160 UTF-8 bytes without controls, slashes, +backslashes or `..`, and the deadline is an integer from 1 to 180 seconds. +Snapshots have `id`, `state`, `error` and a matching `url` on completion. `cancel()` shuts down the connection, stops the payload's next read, and rejects `done` with `AbortError`. Host exit stops and joins the worker. The user must complete the provider's verification in the system browser; the -verified link is captured as Vikingfile's page holds it (a JSON string, slashes -escaped), so the user need not select Download or its advertisements. This API does not bypass verification or open the page; the app +link is captured from the browser's memory as the page makes it, so the user need +not hunt for it. Vikingfile's own page holds it before any Download (a JSON +string, slashes escaped); an anchored host shows it, or starts the download with +it, only after the check, and a download the browser begins can be cancelled. This API does not bypass verification or open the page; the app opens it with `Linking.openURL` after submitting capture. Only a uniquely identified browser network process is read, with identity checked before every read. Scanning is limited to anonymous readable/writable nonexecutable user diff --git a/ps5-react/native/ps5/payloads/browser/README.md b/ps5-react/native/ps5/payloads/browser/README.md index 8cd280e..af4c538 100644 --- a/ps5-react/native/ps5/payloads/browser/README.md +++ b/ps5-react/native/ps5/payloads/browser/README.md @@ -5,7 +5,10 @@ from saawant12/Orbit Store 1.0.1, distributed under GPL-3.0-or-later. Source bundle: https://github.com/saawant12/orbit-store-ps5/releases/download/v1.0.0/orbit-store-1.0.1-source-bundle.zip The bundle contains `orbit-store-1.0.1-source.tar.gz`, backend/browser_*. `browser_url.c` adapts the filename-bound route validation in backend/providers.c. -The release/catalog dependency was replaced by an explicit selected filename. +The release/catalog dependency was replaced by an explicit selected filename. The +same file adds anchor mode: for a host with no fixed route, the app names the +game's title id and the route carried by a file link on a known host, or by the +link's own last path segment, is accepted. From Orbit Store 1.3.5 (same licence), `browser_match.c` decodes JSON's escaped slash once, so the verified Vikingfile link is found before the page's Download @@ -18,4 +21,6 @@ Only the uniquely identified SceNKNetworkProcess/SceNKNetworkProc is read. Identity is rechecked before reads; no executable/file-backed pages, writes, cookies, dumps or arbitrary process selectors. Each session has a 1 GiB total read budget, 64 KiB reads separated by 8 ms, and a maximum 180-second deadline. -Only a complete matching Vikingfile URL is returned. Buffers are wiped. +Only a complete matching route is returned: Vikingfile's own download URL, or, in +anchor mode, a file link on a known host or one whose name carries the title id. +Buffers are wiped. diff --git a/ps5-react/native/ps5/payloads/browser/browser_match.c b/ps5-react/native/ps5/payloads/browser/browser_match.c index d282100..97e622f 100644 --- a/ps5-react/native/ps5/payloads/browser/browser_match.c +++ b/ps5-react/native/ps5/payloads/browser/browser_match.c @@ -9,11 +9,18 @@ static bool boundary(unsigned char c) { return c == 0 || c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '"' || c == '\'' || c == '<' || c == '>'; } +static int hex(unsigned char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} /* One URL character at *position, in ASCII (step 1) or UTF-16LE (step 2). Vikingfile's verified * link is already in the network process once the verification passes, but as JSON, before a * click on the page's Download button (and its advertisements) makes a navigation URL of it - * (Orbit Store 1.3.5). JSON's optional slash escape is decoded, once; no other escape is a - * download address. */ + * (Orbit Store 1.3.5). A captured link comes out of live browser memory, so its escapes are + * whichever the page used: JSON's "\/" for a path slash and "\uXXXX" for a character such as a + * query's "&". Both are decoded once; any other escape is not a download address. */ static bool next_url_char(const unsigned char *bytes, size_t length, size_t step, size_t *position, unsigned char *out) { if (*position > length || length - *position < step) return false; @@ -21,18 +28,30 @@ static bool next_url_char(const unsigned char *bytes, size_t length, size_t step if (step == 2 && bytes[*position + 1]) return false; *position += step; if (c == '\\') { - if (length - *position < step || bytes[*position] != '/' || - (step == 2 && bytes[*position + 1])) return false; - *position += step; - c = '/'; + if (length - *position < step || (step == 2 && bytes[*position + 1])) return false; + if (bytes[*position] == '/') { + *position += step; + c = '/'; + } else if (bytes[*position] == 'u') { + unsigned value = 0; + for (size_t i = 0; i < 4; i++) { + const size_t at = *position + (i + 1) * step; + if (at > length || length - at < step || (step == 2 && bytes[at + 1])) return false; + const int digit = hex(bytes[at]); + if (digit < 0) return false; + value = value * 16 + (unsigned)digit; + } + if (value > 126) return false; /* Only printable ASCII continues a link. */ + *position += 5 * step; + c = (unsigned char)value; + } else return false; } *out = c; return true; } -bool browser_match(void *context, const unsigned char *bytes, size_t length) { - BrowserMatch *match = context; - for (size_t which = 0; which < 2; which++) { - const char *prefix = browser_capture_prefixes[which]; +/* Reads one candidate URL after `prefix` and hands it to the mode's validator. */ +static bool scan_prefix(BrowserMatch *match, const unsigned char *bytes, size_t length, + const char *prefix, bool anchor) { const size_t plen = strlen(prefix); for (size_t step = 1; step <= 2; step++) { for (size_t i = 0; (plen + 1) * step <= length && i <= length - (plen + 1) * step; i++) { @@ -52,7 +71,9 @@ bool browser_match(void *context, const unsigned char *bytes, size_t length) { if (!next_url_char(bytes, length, step, &position, &c)) break; if (boundary(c)) { candidate[j] = 0; - if (browser_capture_url(match->filename, candidate)) { + const bool hit = anchor ? browser_capture_anchor_url(match->filename, candidate) + : browser_capture_url(match->filename, candidate); + if (hit) { memcpy(match->url, candidate, j + 1); return true; } @@ -63,6 +84,13 @@ bool browser_match(void *context, const unsigned char *bytes, size_t length) { } } } - } + return false; +} +bool browser_match(void *context, const unsigned char *bytes, size_t length) { + BrowserMatch *match = context; + if (match->anchor) return match->prefix && scan_prefix(match, bytes, length, match->prefix, true); + /* Vikingfile's two routes are both scanned: a link page may hand out either one. */ + for (size_t which = 0; which < 2; which++) + if (scan_prefix(match, bytes, length, browser_capture_prefixes[which], false)) return true; return false; } diff --git a/ps5-react/native/ps5/payloads/browser/browser_match.h b/ps5-react/native/ps5/payloads/browser/browser_match.h index 569988e..a0e8247 100644 --- a/ps5-react/native/ps5/payloads/browser/browser_match.h +++ b/ps5-react/native/ps5/payloads/browser/browser_match.h @@ -4,8 +4,14 @@ #pragma once #include #include -typedef struct { const char *filename; char url[544]; } BrowserMatch; +typedef struct { + const char *prefix; /* The one prefix to scan for; the request's, in anchor mode. */ + const char *filename; /* The expected filename, or the anchor that stands for it. */ + bool anchor; /* Anchor mode: any file link carrying the anchor, or on a known host. */ + char url[544]; +} BrowserMatch; extern const char *const browser_capture_prefixes[2]; const char *browser_capture_prefix(const char *url); bool browser_capture_url(const char *filename, const char *url); +bool browser_capture_anchor_url(const char *anchor, const char *url); bool browser_match(void *context, const unsigned char *bytes, size_t length); diff --git a/ps5-react/native/ps5/payloads/browser/browser_url.c b/ps5-react/native/ps5/payloads/browser/browser_url.c index a40e5d1..cb1cd34 100644 --- a/ps5-react/native/ps5/payloads/browser/browser_url.c +++ b/ps5-react/native/ps5/payloads/browser/browser_url.c @@ -16,6 +16,95 @@ const char *browser_capture_prefix(const char *url) { if (!strncmp(url, browser_capture_prefixes[i], strlen(browser_capture_prefixes[i]))) return browser_capture_prefixes[i]; return NULL; } +// Hosts whose check only a console browser can pass: Akirabox's Turnstile, Datanodes' and +// Filekeeper's Cloudflare pass, LinkVault's mirror pages. Their file link becomes a real address +// only after the human acts, so it is taken from the browser once the file starts. LinkVault +// itself is not here: a vault page lists mirrors, it never serves the file. +static const char *const browser_capture_hosts[] = { + "akirabox.com", "akirabox.to", "buzzheavier.com", "datanodes.co", "datanodes.to", + "datavaults.co", "fileditchfiles.st", "filekeeper.net", "mediafire.com", "rootz.so", + "vikingfile.com", "vik1ngfile.site", "bhs.io.cloud.ovh.net", "r2.cloudflarestorage.com"}; +// Archives and console images only: a page's .php/.html/.js can never be the download itself. +static const char *const browser_capture_extensions[] = { + "7z", "rar", "zip", "pkg", "exfat", "ffpkg", "ffpfsc", "ffpfs", "bin", "iso", "img"}; +static unsigned char folded(unsigned char c) { + return c >= 'A' && c <= 'Z' ? (unsigned char)(c - 'A' + 'a') : c; +} +static bool token(unsigned char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +} +static bool contains(const char *text, const char *needle) { + const size_t length = strlen(text), wanted = strlen(needle); + if (!wanted) return false; + for (size_t i = 0; i + wanted <= length; i++) { + size_t j = 0; + while (j < wanted && folded((unsigned char)text[i + j]) == folded((unsigned char)needle[j])) j++; + if (j == wanted) return true; + } + return false; +} +// A file token anywhere in the URL: ".ext" of a known kind, then a delimiter or the end. +static bool browser_capture_file_token(const char *url) { + for (const char *dot = strchr(url, '.'); dot; dot = strchr(dot + 1, '.')) { + const char *name = dot + 1; + size_t length = 0; + while (length < 8 && token((unsigned char)name[length])) length++; + const unsigned char after = (unsigned char)name[length]; + if (!length || (after && after != '?' && after != '&' && after != '#' && after != '/')) continue; + for (size_t i = 0; i < sizeof browser_capture_extensions / sizeof *browser_capture_extensions; i++) { + const char *known = browser_capture_extensions[i]; + if (strlen(known) != length) continue; + size_t j = 0; + while (j < length && folded((unsigned char)name[j]) == folded((unsigned char)known[j])) j++; + if (j == length) return true; + } + } + return false; +} +static bool file_host(const char *host, size_t length) { + for (size_t i = 0; i < sizeof browser_capture_hosts / sizeof *browser_capture_hosts; i++) { + const char *known = browser_capture_hosts[i]; + const size_t wanted = strlen(known); + if (length < wanted) continue; + if (length > wanted && host[length - wanted - 1] != '.') continue; + size_t j = 0; + while (j < wanted && folded((unsigned char)host[length - wanted + j]) == folded((unsigned char)known[j])) j++; + if (j == wanted) return true; + } + return false; +} +// Anchor mode: the app names the game's title id and the payload accepts the file link that +// carries it, or, on a known host, a file link that names its archive freely (keeping the id +// nowhere). The name is read from the URL's own last path segment: an advertisement that hides +// the real address inside its query is not the file. Only the app's range probe decides whether +// the captured URL really is the file. +#define BROWSER_CAPTURE_NAME_MAX 320 +bool browser_capture_anchor_url(const char *anchor, const char *url) { + if (!anchor || !*anchor || !url || strncmp(url, "https://", 8)) return false; + const char *host = url + 8; + const char *slash = strchr(host, '/'); + if (!slash || slash == host) return false; + const size_t length = (size_t)(slash - host); + if (length > 256 || !memchr(host, '.', length)) return false; + if (strchr(url, '#') || !browser_capture_file_token(url)) return false; + /* The file's own name: the path's last segment, without the query. An advertisement that keeps + * the real address inside its query has no file name of its own. */ + const char *const path = slash; + const char *const query = strchr(path, '?'); + const char *const end = query ? query : url + strlen(url); + const char *tail = path; + for (const char *at = path; at < end; at++) + if (*at == '/') tail = at + 1; + char name[BROWSER_CAPTURE_NAME_MAX]; + size_t named = 0; + while (tail + named < end && named + 1 < sizeof name) { + name[named] = tail[named]; + named++; + } + name[named] = 0; + if (named && browser_capture_file_token(name) && contains(name, anchor)) return true; + return file_host(host, length) && browser_capture_file_token(url); +} // An empty filename accepts any safe one: link pages don't tell which file they lead to. bool browser_capture_url(const char *filename, const char *url) { if (!filename || !url) return false; diff --git a/ps5-react/native/ps5/payloads/browser_capture.c b/ps5-react/native/ps5/payloads/browser_capture.c index 70261bc..354290f 100644 --- a/ps5-react/native/ps5/payloads/browser_capture.c +++ b/ps5-react/native/ps5/payloads/browser_capture.c @@ -53,10 +53,15 @@ int main(void) { char magic[8], prefix[64], suffix[162], seconds[8]; signal(SIGPIPE, SIG_IGN); if (!line(magic, sizeof magic) || strcmp(magic, "BRC1") || !line(prefix, sizeof prefix) || - strcmp(prefix, "https://vikingfile.com/d/") || !line(suffix, sizeof suffix) || - suffix[0] != '/' || !line(seconds, sizeof seconds)) return fail("Invalid capture request."); + !line(suffix, sizeof suffix) || suffix[0] != '/' || !line(seconds, sizeof seconds)) + return fail("Invalid capture request."); + /* A whole-scheme prefix asks for anchor mode: any file link that carries the anchor (the game's + * title id), or any file link on a known file host, wherever the page leads. */ + const bool anchor = !strcmp(prefix, "https://"); + if (!anchor && strcmp(prefix, "https://vikingfile.com/d/")) return fail("Invalid capture request."); const char *filename = suffix + 1; - if (strchr(filename, '/') || strchr(filename, '\\') || strstr(filename, "..")) return fail("Invalid filename."); + if ((anchor && !*filename) || strchr(filename, '/') || strchr(filename, '\\') || + strstr(filename, "..")) return fail("Invalid filename."); char *end; const long timeout = strtol(seconds, &end, 10); if (*end || timeout < 1 || timeout > 180 || !now()) return fail("Invalid deadline."); @@ -68,7 +73,7 @@ int main(void) { usleep(100000); } if (found != 1) return fail("The browser could not be identified safely. Reopen the page and retry."); - BrowserMatch match = {.filename = filename}; + BrowserMatch match = {.prefix = prefix, .filename = filename, .anchor = anchor}; ProbeReader reader = {.context = &target, .read = probe_target_read, .active = active, .match = browser_match, .match_context = &match, .remaining = PROBE_SESSION_BUDGET}; ProbeResult result = PROBE_ABSENT; diff --git a/ps5-react/native/shared/browser_capture_api.cpp b/ps5-react/native/shared/browser_capture_api.cpp index 1366717..78fc5fc 100644 --- a/ps5-react/native/shared/browser_capture_api.cpp +++ b/ps5-react/native/shared/browser_capture_api.cpp @@ -19,12 +19,16 @@ bool text(JSContext* ctx, JSValueConst value, std::string& out) { JSValue capture(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) { std::string prefix, suffix; double timeout = 0; - if (argc < 3 || !text(ctx, argv[0], prefix) || prefix != "https://vikingfile.com/d/" || + if (argc < 3 || !text(ctx, argv[0], prefix) || + (prefix != "https://vikingfile.com/d/" && prefix != "https://") || !text(ctx, argv[1], suffix) || suffix.empty() || suffix[0] != '/' || suffix.find('/', 1) != std::string::npos || suffix.find('\\') != std::string::npos || - suffix.find("..") != std::string::npos || JS_ToFloat64(ctx, &timeout, argv[2]) || + suffix.find("..") != std::string::npos || + /* The whole scheme means anchor mode, which needs a real anchor. */ + (prefix == "https://" && suffix.size() < 5) || + JS_ToFloat64(ctx, &timeout, argv[2]) || !std::isfinite(timeout) || timeout < 1 || timeout > 180 || std::floor(timeout) != timeout) - return JS_ThrowTypeError(ctx, "browser.capture: expected the Vikingfile prefix, /filename (160 UTF-8 bytes), and 1..180 seconds"); + return JS_ThrowTypeError(ctx, "browser.capture: expected the Vikingfile prefix, or https:// with an anchor of 4..160 bytes, then /filename (160 UTF-8 bytes) and 1..180 seconds"); for (const unsigned char c : suffix) if (c < 32 || c == 127) return JS_ThrowTypeError(ctx, "browser.capture: filename contains a control character"); std::string error; diff --git a/ps5-react/tools/tests/browser_capture_test.c b/ps5-react/tools/tests/browser_capture_test.c index a1cbcaa..1b04a3c 100644 --- a/ps5-react/tools/tests/browser_capture_test.c +++ b/ps5-react/tools/tests/browser_capture_test.c @@ -68,6 +68,70 @@ int main(void) { assert(!strcmp(match.url, candidate)); } } + // Anchor mode: the file link of a host whose check only a console browser can pass. The game's + // title id names the file, or a known file host names it freely; a page or an advertisement that + // merely mentions the address inside its query is never the file. + const char *links[] = { + "https://datanodes.to/ywsurrfeqhj2/PPSA01285.ffpkg", + "https://akirabox.to/rk9zKDZRQG0l/PPSA01285-Compressed.7z", + "https://filekeeper.net/9f3a2b/G_02225(v02.024)-[DLPSGAME.COM].rar", + "https://cdn9.example.ru/9f3/PPSA01285.exfat?token=8f2a", + "https://bucket.r2.cloudflarestorage.com/GAMES/PPSA01285.ffpkg", + "https://datanodes.to/x/ppsa01285.ffpkg" + }; + for (size_t link = 0; link < sizeof links / sizeof *links; link++) + assert(browser_capture_anchor_url("PPSA01285", links[link])); + /* On a host of its own, only the anchor can vouch for the file, so another game's id is refused. */ + const char *anchored[] = { + "https://cdn9.example.ru/9f3/PPSA01285.exfat?token=8f2a", + "https://games.cdn8.example/2026/PPSA01285.ffpkg", + "https://files.example.net/x/PPSA01285-Compressed.7z" + }; + for (size_t link = 0; link < sizeof anchored / sizeof *anchored; link++) { + assert(browser_capture_anchor_url("PPSA01285", anchored[link])); + assert(!browser_capture_anchor_url("PPSA01341", anchored[link])); + } + const char *not_links[] = { + "https://link-vault.org/c/oeSJ8lHw", + "https://link-vault.org/c/oeSJ8lHw#AkiraBox", + "https://akirabox.to/rk9zKDZRQG0l/file", + "https://datanodes.to/ywsurrfeqhj2/", + "https://ads.example.com/click?u=https://datanodes.to/x/PPSA01285.ffpkg&ref=1", + "https://ads.example.com/video.mp4?file=https://datanodes.to/x/PPSA01285.ffpkg", + "https://akirabox.to/login.php", + "https://akirabox.to/x/archive.zip.txt", + "http://datanodes.to/x/PPSA01285.ffpkg", + "https://datanodes.to/x/PPSA01285", + "https://fileditchfiles.st/balpha23/9f3/PPSA01285.7z.001", + "https://datavaults.co/PPSA01285" + }; + for (size_t link = 0; link < sizeof not_links / sizeof *not_links; link++) + assert(!browser_capture_anchor_url("PPSA01285", not_links[link])); + /* A known file host is also allowed to name its archive freely, without the title id. */ + assert(browser_capture_anchor_url("PPSA01285", "https://www.filekeeper.net/9f3/Atomic_Heart.rar")); + assert(browser_capture_anchor_url("PPSA01285", "https://datanodes.to/x/PPSA01285.part01.rar?token=1")); + assert(!browser_capture_anchor_url("PPSA01285", "https://filekeeper.net.evil.test/9f3/Atomic.rar")); + assert(!browser_capture_anchor_url("", "https://datanodes.to/x/PPSA01285.ffpkg")); + // The hosted page hands out its own file address, which the anchor mode picks up before any click. + const char *page = ""; + match.prefix = "https://"; match.anchor = true; match.filename = "PPSA01285"; + memset(match.url, 0, sizeof match.url); + assert(browser_match(&match, (const unsigned char *)page, strlen(page))); + assert(!strcmp(match.url, "https://datanodes.to/ywsurrfeqhj2/PPSA01285.ffpkg")); + // A query whose "&" the page escaped as JSON still yields the address the browser will request. + const char *escaped = "fetch(\"https://cdn9.example.ru/9f3/PPSA01285.exfat?token=8f2a\\u0026expires=99\");"; + memset(match.url, 0, sizeof match.url); + assert(browser_match(&match, (const unsigned char *)escaped, strlen(escaped))); + assert(!strcmp(match.url, "https://cdn9.example.ru/9f3/PPSA01285.exfat?token=8f2a&expires=99")); + for (size_t step = 1; step <= 2; step++) { + memset(memory.bytes, 0, memory.length); + memset(match.url, 0, sizeof match.url); + const char *candidate = links[0]; + const size_t offset = PROBE_CHUNK - 21; + for (size_t i = 0; i < strlen(candidate); i++) memory.bytes[offset + i * step] = (unsigned char)candidate[i]; + assert(scan(&memory, &match, memory.length) == PROBE_FOUND); + assert(!strcmp(match.url, candidate)); + } free(memory.bytes); puts("browser matcher/scanner: ASCII, UTF-16, block overlap, holes, cancellation, budget and URL binding passed"); }