feat: browser capture for LinkVault, Akirabox and Datanodes

This commit is contained in:
MyLuxy committed 2026-10-15 22:06:14 +02:00
1 parent 7454d7be1c
commit 6cd4add902
14 files changed
+386 -34

No files matched your search

+2 -2
View File
@@ -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 });
} }),
+3 -1
View File
@@ -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."
}
+2
View File
@@ -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…",
+71
View File
@@ -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(/&#0*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 };
+58 -3
View File
@@ -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.");
+10
View File
@@ -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
+17 -6
View File
@@ -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");
}