mirror of
https://github.com/MrLowPr/overdrive.git
synced 2026-10-17 13:01:11 +02:00
feat: browser capture for LinkVault, Akirabox and Datanodes
This commit is contained in:
1 parent
7454d7be1c
commit
6cd4add902
14 files changed
+386
-34
No files matched your search
@@ -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 });
|
||||
} }),
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 };
|
||||
@@ -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.");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,8 +4,14 @@
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = "<a class=\"btn\" href=\"https:\\/\\/datanodes.to\\/ywsurrfeqhj2\\/PPSA01285.ffpkg\">";
|
||||
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");
|
||||
}
|
||||
Reference in new issue
Block a user