From bcf50ec81ac39b326a79e40eb16605db5e9d7376 Mon Sep 17 00:00:00 2001 From: Codeon <313085171+codeon89@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:07:43 -0700 Subject: [PATCH 1/2] feat: add Gofile host with self-healing salt rotation --- CHANGELOG.md | 1 + electron/downloads/downloadManager.js | 5 + electron/downloads/hosts/gofile.js | 570 +++++++++++++++++++ electron/downloads/hosts/index.js | 7 +- electron/ipc/downloads.js | 18 + electron/ipc/updateLinks.js | 1 + electron/preload.js | 1 + scripts/check-host-plugins.js | 95 +++- src/components/downloads/UpdateModal.jsx | 103 +++- src/components/settings/DownloadAccounts.jsx | 47 +- tests/download-accounts.test.jsx | 74 ++- tests/download-manager-folders.test.js | 112 ++++ tests/downloads-list-folder.test.js | 141 +++++ tests/gofile.test.js | 570 +++++++++++++++++++ tests/update-modal-options.test.jsx | 105 +++- 15 files changed, 1819 insertions(+), 31 deletions(-) create mode 100644 electron/downloads/hosts/gofile.js create mode 100644 tests/download-manager-folders.test.js create mode 100644 tests/downloads-list-folder.test.js create mode 100644 tests/gofile.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f8cfb175..274423f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Path resolution highlighting: red if invalid, green if path exists or pass the check. ### Added +- Add Gofile support. One-file folders queue directly; multi-file folders show a picker. Settings shows free usage (1 TB / 30 days), no login needed. The site token salt is cached locally and re-fetched automatically when Gofile rotates it, so links keep working without updates; guests share one session and one 4s-paced retry per incident to stay inside the ~20 calls/min free budget, with a wait-and-retry message when throttled. - Custom media uploads in the Game Details Media tab: add preview images from local files, a drag-and-drop zone, or an image URL, with live progress. Previews can be reordered by drag and the order persists in a new `preview_sort` table keyed by remote URL (or relative path for custom uploads), so it survives re-downloads, stream/download switches and metadata refreshes. Previews now carry a source logo and a storage-location badge, and custom previews can be deleted independently of downloaded ones. - Add Buzzheavier host support (`buzzheavier.com`, `bzzhr.to`, `bzzhr.co`). The download route is behind a Cloudflare challenge, so the resolve runs in a browser window: it clicks the htmx download button, captures the `HX-Redirect` (or an attachment via `will-download`), and hands the resolved CDN link plus the browser's own cookies/UA back to the downloader. The resolve partition is persistent so a solved challenge survives a restart -- only Cloudflare's own challenge cookies are kept, everything else is stripped after each resolve -- and resolves run one at a time, since a shared session cannot carry two concurrently. Each time your IP changes there is a brief auto-resolve window while the challenge is re-solved. - The version readout in the topnav and sidebar is now a button that opens that version's GitHub release page. The tag it builds matches what the release workflows publish -- `v` for stable and `v-nightly.` for nightly -- so it lands on the real release rather than a 404. (#143) diff --git a/electron/downloads/downloadManager.js b/electron/downloads/downloadManager.js index ccaa8cd9..3f9bbbf7 100644 --- a/electron/downloads/downloadManager.js +++ b/electron/downloads/downloadManager.js @@ -344,6 +344,11 @@ const startTransfer = async (item) => { return; } if (!probe.passthrough) { + if (probe.choices) { + await handleFailure(item.id, "fatal", + `This folder has ${probe.choices.length} files. Pick one to download.`); + return; + } transferUrl = probe.directUrl || item.url; decryptSpec = probe.decrypt || null; Object.assign(headers, probe.headers || {}); diff --git a/electron/downloads/hosts/gofile.js b/electron/downloads/hosts/gofile.js new file mode 100644 index 00000000..2ecb8da6 --- /dev/null +++ b/electron/downloads/hosts/gofile.js @@ -0,0 +1,570 @@ +"use strict"; + +// Gofile host plugin, free route. Premium API for direct links will be +// another approach when needed. + +const { createHash } = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +// Free allowance in decimal bytes, matching gofile.io display. +// This is not mentioned in documents, but it's the current limit for Guest account +const GUEST_TRAFFIC_CAP = 1000000000000; +const TRAFFIC_WINDOW_DAYS = 30; + +const API_BASE = "https://api.gofile.io"; + +// Part of the website-token hash: the sent UA must be the hashed UA. +const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; +const WT_LANG = "en-US"; +// No default WL_SALT. A hardcoded salt rots and every install fails at +// once. The persisted value (userData/gofile-salt.json) is the source of +// truth; with no stored value the first probe fetches WL_URLS live, takes +// the top candidate, stores it, and proceeds with it below. +const WL_URLS = [ + "https://gofile.io/js/wt.obf.js", + "https://gofile.io/dist/js/wt.obf.js", +]; +const WT_WINDOW_SECONDS = 14400; +const id = "gofile"; +const label = "Gofile"; +const supportsAnonymous = true; + +// Store hosts serve picked files rather than share pages. +function isStoreHost(host) { + const name = String(host || ""); + return name.endsWith(".gofile.io") && name !== "api.gofile.io" + && name !== "gofile.io" && name !== "www.gofile.io"; +} + +function matches(url) { + let host = ""; + try { + host = new URL(String(url || "")).hostname.toLowerCase(); + } catch { + return false; + } + if (host === "gofile.io" || host === "www.gofile.io") return true; + // Store hosts serve picked files and need a cookie, so claim them to re-probe. + return isStoreHost(host); +} + +function fileIdFrom(url) { + const text = String(url || ""); + const path = text.match(/gofile\.io\/d\/([A-Za-z0-9]+)/i); + if (path) return path[1]; + const param = text.match(/[?&]c=([A-Za-z0-9]+)/i); + return param ? param[1] : null; +} + +function websiteToken(token, nowMs = Date.now(), salt = "") { + const window = Math.floor(nowMs / 1000 / WT_WINDOW_SECONDS); + return createHash("sha256") + .update(`${UA}::${WT_LANG}::${token}::${window}::${salt}`) + .digest("hex"); +} + +function userDataDir() { + // Test override; production resolves through Electron below. + if (process.env.ATLAS_USER_DATA) return process.env.ATLAS_USER_DATA; + try { + // Unavailable under plain node: callers then run memory-less, which only + // costs a refetch on rotation. + return require("electron").app.getPath("userData"); + } catch { + return null; + } +} + +function isValidSalt(value) { + return typeof value === "string" && /^[A-Za-z0-9]{8,64}$/.test(value); +} + +// Last persisted salt, or null when there is none (fresh install, corrupt +// file, or no userData dir). Never throws. Plain JSON, no encryption: the +// salt is public (shipped in Gofile's own script) and anonymous downloads +// must not depend on an OS keychain. +function loadSalt() { + try { + const dir = userDataDir(); + if (!dir) return null; + const parsed = JSON.parse(fs.readFileSync(path.join(dir, "gofile-salt.json"), "utf8")); + const salt = parsed && parsed.salt; + return isValidSalt(salt) ? salt : null; + } catch { + return null; + } +} + +// Persist a server-validated salt. Best-effort: a failed write only means the +// next probe refetches instead of reusing. +function saveSalt(salt) { + if (!isValidSalt(salt)) return false; + try { + const dir = userDataDir(); + if (!dir) return false; + const target = path.join(dir, "gofile-salt.json"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify({ salt }), { mode: 0o600 }); + return true; + } catch (err) { + console.warn("[gofile-salt]", `persist failed for ${salt}:`, err?.message || err); + return false; + } +} + +// Candidates scraped from the live script. The salt ships as one contiguous +// \x run while obfuscator blobs are plain base64, so that pass isolates it; +// hex-shaped then generic scans are fallback for a future non-hex shape. +function extractSalts(script) { + const raw = String(script || ""); + const found = []; + const push = (s) => { if (s && !found.includes(s)) found.push(s); }; + const decode = (s) => s.replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))); + const text = decode(raw); + for (const m of raw.matchAll(/((?:\\x[0-9a-fA-F]{2}){12,16})/g)) { + const s = decode(m[1]); + if (/^[0-9a-fA-F]+$/.test(s) && /[A-Za-z]/.test(s) && /[0-9]/.test(s)) push(s); + if (found.length >= 8) break; + } + if (found.length < 8) { + for (const m of text.matchAll(/[0-9a-fA-F]{12,16}/g)) { + const s = m[0]; + if (!/[A-Za-z]/.test(s) || !/[0-9]/.test(s)) continue; + push(s); + if (found.length >= 8) break; + } + } + if (found.length < 8) { + for (const m of text.matchAll(/[A-Za-z0-9]{12,24}/g)) { + const s = m[0]; + if (!/[A-Za-z]/.test(s) || !/[0-9]/.test(s)) continue; + push(s); + if (found.length >= 8) break; + } + } + return found.slice(0, 8); +} + +// One shared fetch per rotation incident: concurrent probes reuse it instead +// of each downloading the script. Browser headers: the script host WAFs bare +// fetches. Empty when unreachable, degrading to the transient error below. +let inflightSalts = null; + +async function refreshSalts(exclude = new Set()) { + if (!inflightSalts) { + inflightSalts = (async () => { + for (const scriptUrl of WL_URLS) { + try { + const res = await fetch(scriptUrl, { + cache: "no-store", + headers: { + "user-agent": UA, + referer: "https://gofile.io/", + origin: "https://gofile.io", + }, + }); + if (!res.ok) continue; + return extractSalts(await res.text()); + } catch { + continue; + } + } + return []; + })().finally(() => { inflightSalts = null; }); + } + return (await inflightSalts).filter((s) => !exclude.has(s)); +} + +function classifyError(err, { status = 0, body = null } = {}) { // Exact Gofile statuses only (see gofile.io/api status table). Anything + // unknown stays retryable and gets reported, never faked fatal/quota. + // Order matters: error-notPremium arrives as HTTP 401, so it must precede + // the 401 -> auth rule. + const s = String(body?.status || err?.gofileStatus || ""); + if (/password/i.test(s)) return "fatal"; + if (s === "error-notFound") return "fatal"; + if (status === 404 || status === 410) return "fatal"; + if (status === 429 || s === "error-rateLimit" || s === "error-limits") return "quota"; + if (s === "error-notPremium") return "transient"; + // Guest-only: no account-id or ownership calls exist here, so only token + // failures are reachable. Owner/accountId statuses are deliberately unmapped. + if (status === 401 || status === 403 || s === "error-token" || s === "error-wrongToken") return "auth"; + return "transient"; +} + +// Advice only for API codes we know; unknown codes pass through untouched. +const API_ADVICE = { + "error-rateLimit": "Rate limited. Wait 1-2 mins and try again.", + "error-limits": "Rate limited. Wait 1-2 mins and try again.", + "error-notFound": "The folder may have expired — check it in your browser.", + "error-wrongToken": "Session rejected. Try again — a fresh guest session is minted automatically.", + "error-token": "Session rejected. Try again — a fresh guest session is minted automatically.", +}; + +async function readJson(response) { + try { + return await response.json(); + } catch { + return null; + } +} + +// Network failures need a next step, not just the raw error. Guests get +// ~20 API calls/min, and retry-mashing a throttle extends it. +function netError(what, err) { + return `Could not reach Gofile ${what} (${err?.message || err}). Check your connection, wait a bit, then try again.`; +} + +async function createGuestAccount() { + const response = await fetch(`${API_BASE}/accounts`, { + method: "POST", + headers: { "user-agent": UA, "content-type": "application/json" }, + body: "{}", + }); + const body = await readJson(response); + const token = body?.data?.token; + if (!response.ok || body?.status !== "ok" || !token) { + const err = new Error(body?.status && body.status !== "ok" + ? `Gofile returned ${body.status}` + : `Gofile returned ${response.status} creating a guest account`); + err.gofileStatus = body?.status || ""; + throw err; + } + return { token, id: body?.data?.id || null }; +} + +// One guest token per session: each mint spends guest-budget calls (20/min). +// A rejected token drops the cache and mints once (see token retry in probe). +let cachedGuest = null; +async function getGuestAccount() { + if (cachedGuest) return cachedGuest.account; + const account = await createGuestAccount(); + cachedGuest = { account }; + return account; +} +function dropGuestAccount() { + cachedGuest = null; +} + +// Single listing attempt at one time window. Callers handle windows: giving +// every candidate its own previous-window retry would double junk candidates +// into a rate-limit incident. +async function fetchListing(code, token, salt, nowMs) { + const params = new URLSearchParams({ + contentFilter: "", + page: "1", + pageSize: "1000", + sortField: "createTime", + sortDirection: "-1", + }); + const target = `${API_BASE}/contents/${code}?${params}`; + const response = await fetch(target, { + headers: { + "user-agent": UA, + authorization: `Bearer ${token}`, + "x-website-token": websiteToken(token, nowMs, salt), + "x-bl": WT_LANG, + origin: "https://gofile.io", + referer: "https://gofile.io/", + }, + }); + return { response, body: await readJson(response), requested: target }; +} + +// One file -> directUrl. Several files -> `choices` for the modal picker; a +// folder reaching the queue is refused there, never silently first-filed. +async function probe(url) { + const code = fileIdFrom(url); + let host = ""; + try { + host = new URL(String(url || "")).hostname.toLowerCase(); + } catch { + host = ""; + } + if (!code) { + if (isStoreHost(host)) { + try { + const { token } = await getGuestAccount(); + return { + ok: true, + diagnostic: { requested: String(url).split(/[?#]/)[0] }, + directUrl: String(url).split(/[?#]/)[0], + fileName: "", + fileSize: 0, + headers: { cookie: `accountToken=${token}` }, + }; + } catch (err) { + return { + ok: false, + kind: classifyError(err, { body: { status: err.gofileStatus || "" } }), + error: netError("for a guest session", err), + diagnostic: { requested: `${API_BASE}/accounts` }, + }; + } + } + return { + ok: false, + kind: "fatal", + error: "Atlas does not recognise this Gofile link format. Opening it in a " + + "browser will show whether the files are still there; please report it either way.", + }; + } + + let token; + try { + ({ token } = await getGuestAccount()); + } catch (err) { + return { + ok: false, + kind: classifyError(err, { body: { status: err.gofileStatus || "" } }), + error: netError("for a guest session", err), + diagnostic: { requested: `${API_BASE}/accounts`, stage: "guest-account" }, + }; + } + + let listing; + // Stored salt first; with none, fetch the live top seed and proceed with + // it — the listing below validates it, rotation heals a miss. Never saved + // here: only a server-validated salt may persist (see below). + const fromStore = loadSalt(); + let stored = fromStore; + if (!stored) { + const fresh = await refreshSalts(); + stored = fresh[0] || null; + if (!stored) { + return { + ok: false, + kind: "transient", + error: "Gofile isn't letting Atlas read this folder right now. They may have " + + "changed something on their end -- please report it so it can be fixed.", + diagnostic: { requested: `${API_BASE}/contents/${code}`, code, stage: "seed" }, + }; + } + } + console.log("[gofile-salt]", `probe ${code}: salt ${stored} (${fromStore ? "stored" : "live"})`); + const now = Date.now(); + try { + listing = await fetchListing(code, token, stored, now); + } catch (err) { + return { + ok: false, + kind: classifyError(err), + error: netError("for this folder", err), + diagnostic: { requested: `${API_BASE}/contents/${code}`, code, stage: "listing" }, + }; + } + // Same hash, previous window: a boundary-crossing clock reads as a rotated + // salt, so rule skew out before paying for a script fetch. + if (/token/i.test(String(listing?.body?.status || ""))) { + // Cached token rejected (IP change, expiry): mint fresh once and retry. + // Anything else still failing is reported, never looped. + dropGuestAccount(); + try { + ({ token } = await getGuestAccount()); + listing = await fetchListing(code, token, stored, Date.now()); + } catch (err) { + return { + ok: false, + kind: classifyError(err), + error: netError("for this folder", err), + diagnostic: { requested: `${API_BASE}/contents/${code}`, code, stage: "listing-retry" }, + }; + } + } + if (listing?.body?.status === "error-notPremium") { + try { + const prev = await fetchListing(code, token, stored, now - WT_WINDOW_SECONDS * 1000); + if (prev?.body?.status !== "error-notPremium") listing = prev; + } catch { + // Keep the original listing; the live retry below still runs. + } + } + // Stored salt rejected on both windows: refetch live and retry alternates, + // persisting the first server-validated one. A bad hash can also surface + // as rateLimit, or a bare HTTP 429 with no JSON body; both signal + // rotation, never a verdict. + const rejected = String(listing?.body?.status || ""); + let rotated = false; + if (rejected === "error-notPremium" || rejected === "error-rateLimit" || rejected === "error-limits" + || listing?.response?.status === 429) { + console.log("[gofile-salt]", `probe ${code}: salt ${stored} rejected (${listing?.response?.status} ${rejected}), refetching live`); + const pending = await refreshSalts(new Set([stored])); + // One pause before trying: setup already spent guest-budget calls, and a + // throttled burst answers 429 to even the correct hash. + // ATLAS_GOFILE_PAUSE_MS overrides the wait (tests use 0). + if (pending.length) await new Promise((r) => setTimeout(r, Number(process.env.ATLAS_GOFILE_PAUSE_MS || 4000))); + const settle = async (when) => { + for (const salt of pending) { + let retry; + try { + retry = await fetchListing(code, token, salt, when); + } catch { + continue; + } + const verdict = String(retry?.body?.status || ""); + // First throttled answer ends rotation: the budget is gone and every + // further try extends the wall. Null keeps the original listing. + if (!verdict || /token|ratelimit|limits/i.test(verdict) || retry?.response?.status === 429) { + return null; + } + if (verdict !== "error-notPremium") { + // Only a post-auth verdict proves the hash validated. + if (verdict === "ok" || /notfound|password/i.test(verdict)) { + saveSalt(salt); + console.log("[gofile-salt]", `probe ${code}: stored new salt ${salt}`); + } + return retry; + } + } + return null; + }; + const settled = await settle(now); + if (settled) listing = settled; + rotated = true; + } + const { response, body, requested } = listing; + const diagnostic = { requested, code, status: response.status, gofileStatus: body?.status || "" }; + // Happy path proves the salt too: persist it so the next probe starts from + // a known-good stored value. Skipped after a rotation, which already + // persisted its validated salt. + if (!rotated && body?.status === "ok" && loadSalt() !== stored) saveSalt(stored); + + if (!response.ok || !body || body.status !== "ok") { + const status = String(body?.status || ""); + if (/password/i.test(status)) { + return { + ok: false, + kind: "fatal", + error: "This Gofile folder is password-protected. Atlas cannot open those, " + + "so grab the file from your browser instead.", + diagnostic, + }; + } + if (/notpremium/i.test(status)) { + return { + ok: false, + kind: "transient", + error: "Gofile isn't letting Atlas read this folder right now. They may have " + + "changed something on their end -- please report it so it can be fixed.", + diagnostic, + }; + } + const kind = classifyError(null, { status: response.status, body }); + const code = String(body?.status || response.status || ""); + const advice = API_ADVICE[code] || (response.status === 429 ? API_ADVICE["error-rateLimit"] : ""); + return { + ok: false, + kind, + error: `Gofile returned ${code || "an unreadable response"}` + (advice ? `. ${advice}` : ""), + diagnostic, + }; + } + + const children = Object.values(body?.data?.children || {}); + const files = children.filter((child) => child?.type !== "folder" && child?.link); + if (files.length === 0) { + return { + ok: false, + kind: "fatal", + error: children.length > 0 + ? "This Gofile folder holds only subfolders. Atlas reads one level, so open " + + "it in your browser and pick the file you want." + : "This Gofile folder is empty or expired.", + diagnostic, + }; + } + if (files.length > 1) { + return { + ok: true, + diagnostic, + choices: files.map((file) => ({ + name: String(file.name || "unnamed"), + size: Number(file.size) || 0, + directUrl: String(file.link), + })), + }; + } + + const file = files[0]; + return { + ok: true, + diagnostic, + directUrl: String(file.link), + fileName: String(file.name || `gofile-${code}`), + fileSize: Number(file.size) || 0, + // Store checks the cookie, not the auth header. Without it: 401. + headers: { cookie: `accountToken=${token}` }, + }; +} + +async function validate() { + return { ok: true, anonymous: true }; +} + +// Sum the last 30 days of the usage buckets in account details. Day values +// are normally byte counts, but deeper (hourly) leaves are summed too so a +// shape change undercounts nothing; undated leaves are skipped. +function trafficUsed(history) { + if (!history || typeof history !== "object") return null; + const cutoff = Date.now() - TRAFFIC_WINDOW_DAYS * 24 * 60 * 60 * 1000; + const sumLeaves = (node, time) => { + const amount = typeof node === "number" ? node : (typeof node === "string" && node.trim() !== "" ? Number(node) : NaN); + if (Number.isFinite(amount)) return time >= cutoff ? amount : 0; + if (!node || typeof node !== "object") return 0; + let sub = 0; + for (const value of Object.values(node)) sub += sumLeaves(value, time); + return sub; + }; + let used = 0; + for (const [year, months] of Object.entries(history)) { + if (!months || typeof months !== "object") continue; + for (const [month, days] of Object.entries(months)) { + if (!days || typeof days !== "object") continue; + for (const [day, bytes] of Object.entries(days)) { + used += sumLeaves(bytes, Date.UTC(Number(year), Number(month) - 1, Number(day))); + } + } + } + return used; +} + +async function getQuota() { + try { + // Cached token: same IP attribution, one less budget call per check. + const { token, id } = await getGuestAccount(); + if (!id) return { ok: false, error: "Could not read the quota response" }; + const response = await fetch(`${API_BASE}/accounts/${id}`, { + headers: { "user-agent": UA, authorization: `Bearer ${token}` }, + }); + const body = await readJson(response); + const data = body?.data; + if (!response.ok || body?.status !== "ok" || !data) { + return { ok: false, error: "Could not read the quota response" }; + } + const used = trafficUsed(data.ipTraffic); + if (used == null) return { ok: false, error: "Could not read the quota response" }; + const cap = data.tier === "guest" ? GUEST_TRAFFIC_CAP : null; + return { ok: true, used, cap }; + } catch (err) { + return { ok: false, error: err.message || String(err) }; + } +} + +module.exports = { + id, + label, + supportsAnonymous, + quotaWithoutAccount: true, + quotaTemplate: `Transfer used: {used} of {cap} per ${TRAFFIC_WINDOW_DAYS} days`, + hostAliases: ["gofile"], + credentialFields: [], + matches, + probe, + validate, + getQuota, + classifyError, + fileIdFrom, + websiteToken, + saltStore: { load: loadSalt, save: saveSalt, refresh: refreshSalts, extractSalts, resetGuest: dropGuestAccount }, +}; diff --git a/electron/downloads/hosts/index.js b/electron/downloads/hosts/index.js index 62c89f46..2e63ee28 100644 --- a/electron/downloads/hosts/index.js +++ b/electron/downloads/hosts/index.js @@ -32,10 +32,11 @@ const pixeldrain = require("./pixeldrain"); const buzzheavier = require("./buzzheavier"); const mega = require("./mega"); +const gofile = require("./gofile"); // Order matters only for overlapping matchers, of which there are none: each -// plugin claims a distinct domain. Add Gofile and Mega here as they land. -const plugins = [pixeldrain, buzzheavier, mega]; +// plugin claims a distinct domain. +const plugins = [pixeldrain, buzzheavier, mega, gofile]; // A plugin can be present but not offered. `disabled: true` keeps it resolvable // for a download already in the queue while removing it from everything that @@ -85,6 +86,8 @@ function listPlugins() { id: plugin.id, label: plugin.label, supportsAnonymous: plugin.supportsAnonymous !== false, + quotaWithoutAccount: plugin.quotaWithoutAccount === true, + quotaTemplate: plugin.quotaTemplate || null, })); } diff --git a/electron/ipc/downloads.js b/electron/ipc/downloads.js index 87edaebd..c2d73a4c 100644 --- a/electron/ipc/downloads.js +++ b/electron/ipc/downloads.js @@ -156,6 +156,24 @@ function registerDownloadsHandlers(ctx = {}) { } }); + // Folder listing for the update modal's picker. The modal cannot probe itself + // (renderer has no plugin access), and the queue only takes single files. + ipcMain.handle("downloads-list-folder", async (event, { url } = {}) => { + try { + if (!url) return { ok: false, error: "No URL supplied" }; + const plugin = pluginFor(url); + if (!plugin) return { ok: false, error: "No plugin for this host" }; + const result = await plugin.probe(url, credentialStore.getCredentials(plugin.id)); + if (!result?.ok) return { ok: false, error: result?.error || "Could not read this folder" }; + if (result.choices) return { ok: true, choices: result.choices }; + // Single file: hand the direct URL back so the modal queues it without + // a second probe spending guest budget. + return { ok: true, directUrl: result.directUrl, fileName: result.fileName, fileSize: result.fileSize }; + } catch (err) { + return { ok: false, error: err.message || String(err) }; + } + }); + // Version suggestion for the install prompt. Lives here rather than in the // renderer because it reconciles the archive filename against the catalog's // version, and the parser and the record both sit on this side. diff --git a/electron/ipc/updateLinks.js b/electron/ipc/updateLinks.js index 0e9d6ede..dde582f4 100644 --- a/electron/ipc/updateLinks.js +++ b/electron/ipc/updateLinks.js @@ -214,6 +214,7 @@ function registerUpdateLinkHandlers() { if (!threadId) return { ok: false, error: "No F95 thread id for this game" }; return await getUpdateLinks(threadId, { force }); } catch (err) { + console.warn(`update-links-get failed for thread ${threadId ?? '?'}:`, err.message || err); return { ok: false, code: err.code || "", error: err.message || String(err) }; } }); diff --git a/electron/preload.js b/electron/preload.js index f6977cf4..93819532 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -357,6 +357,7 @@ contextBridge.exposeInMainWorld("electronAPI", { downloadsOpenFolder: () => ipcRenderer.invoke("downloads-open-folder"), downloadsAttachFile: (params) => ipcRenderer.invoke("downloads-attach-file", params), downloadsResolveMasked: (params) => ipcRenderer.invoke("downloads-resolve-masked", params), + downloadsListFolder: (params) => ipcRenderer.invoke("downloads-list-folder", params), downloadsInstall: (params) => ipcRenderer.invoke("downloads-install", params), downloadsSuggestVersion: (params) => ipcRenderer.invoke("downloads-suggest-version", params), hostsList: () => ipcRenderer.invoke("hosts-list"), diff --git a/scripts/check-host-plugins.js b/scripts/check-host-plugins.js index 49c694a6..3b1ddc42 100644 --- a/scripts/check-host-plugins.js +++ b/scripts/check-host-plugins.js @@ -14,6 +14,7 @@ const assert = require("assert"); const pixeldrain = require("../electron/downloads/hosts/pixeldrain"); const buzzheavier = require("../electron/downloads/hosts/buzzheavier"); +const gofile = require("../electron/downloads/hosts/gofile"); const registry = require("../electron/downloads/hosts"); const { selectDownloadableLinks } = require("../electron/downloads/groupClassifier"); @@ -453,13 +454,103 @@ const ok = (condition, message) => { assert.ok(condition, message); checks += 1; // it would need "buzz" in the supported set, and no plugin has ever claimed // it. Asserted so a future alias cannot reintroduce it by accident. ok(!registry.supportedHostIds().includes("buzz"), "buzz.to is not offered either"); - // pixeldrain, mega, and buzzheavier (with its bzzhr alias). - eq(registry.supportedHostIds().length, 4, "four offered host labels"); + // pixeldrain, mega, buzzheavier (with its bzzhr alias), and gofile. + eq(registry.supportedHostIds().length, 5, "five offered host labels"); ok(registry.supportedHostIds().includes("mega"), "mega is offered"); } finally { global.fetch = realFetch; } + // ── Gofile ────────────────────────────────────────────────────────────── + // Share pages are JS shells, so the plugin reads the folder through the same + // API the web client uses. These pin recognition, the token request shape, + // and the one-file vs multi-file split the modal picker depends on. + ok(gofile.matches("https://gofile.io/d/AbCdEfGh"), "share page matched"); + ok(gofile.matches("https://store1.gofile.io/download/web/u1/game.zip"), "store host claimed for a fresh cookie"); + ok(!gofile.matches("https://pixeldrain.com/u/x"), "other hosts not claimed"); + eq(gofile.fileIdFrom("https://gofile.io/d/AbCdEfGh"), "AbCdEfGh", "id extracted"); + eq(gofile.fileIdFrom("https://gofile.io/?c=AbCd12"), "AbCd12", "legacy param id extracted"); + eq(gofile.fileIdFrom("https://gofile.io/pricing"), null, "site route is not a folder id"); + eq(gofile.classifyError(null, { status: 429 }), "quota", "429 is quota"); + eq(gofile.classifyError(null, { body: { status: "error-notFound" } }), "fatal", "notFound is fatal"); + eq(gofile.classifyError(new Error("ECONNRESET")), "transient", "socket error retries"); + eq(gofile.quotaWithoutAccount, true, "quota loads with no account saved"); + // The renderer never sees the module, only the serialized list shape. + eq(registry.listPlugins().find((p) => p.id === "gofile")?.quotaWithoutAccount, true, "flag survives listPlugins"); + eq(registry.listPlugins().find((p) => p.id === "gofile")?.quotaTemplate, "Transfer used: {used} of {cap} per 30 days", "template survives listPlugins"); + + try { + const child = (name, size, link) => ({ id: "u", type: "file", name, size, link }); + // Seedless probes fetch the script first; shared so each stub needn't. + const withScript = (handler) => stub(async (url, init) => { + if (String(url).includes("wt.obf.js")) { + return { ok: true, status: 200, text: async () => 'salt="bb22cc33dd44ee"' }; + } + return handler(url, init); + }); + { + let seen = null; + withScript(async (url, init) => { + if (String(url).includes("/accounts")) { + seen = { url, init }; + return jsonResponse({ status: "ok", data: { token: "guest-1", id: "account-1" } }); + } + return jsonResponse({ status: "ok", data: { children: { + a: child("game.zip", 7, "https://store1.gofile.io/download/web/u/game.zip"), + } } }); + }); + const result = await gofile.probe("https://gofile.io/d/AbCdEfGh"); + eq(result.ok, true, "one-file folder resolves"); + eq(seen.init.method, "POST", "guest account created first"); + eq(result.directUrl, "https://store1.gofile.io/download/web/u/game.zip", "child link used verbatim"); + eq(result.headers.cookie, "accountToken=guest-1", "store cookie carried to the transfer"); + } + { + withScript(async (url) => { + if (String(url).includes("/accounts")) { + return jsonResponse({ status: "ok", data: { token: "guest-1" } }); + } + return jsonResponse({ status: "ok", data: { children: { + a: child("part1.zip", 1, "https://store1.gofile.io/1"), + b: child("part2.zip", 2, "https://store1.gofile.io/2"), + } } }); + }); + const result = await gofile.probe("https://gofile.io/d/AbCdEfGh"); + eq(result.ok, true, "multi-file folder resolves"); + eq(result.directUrl, undefined, "no silent first-file grab"); + eq(result.choices.length, 2, "both files offered to the picker"); + } + { + withScript(async (url) => { + if (String(url).includes("/accounts")) { + return jsonResponse({ status: "ok", data: { token: "guest-1" } }); + } + throw new Error(`unstubbed fetch: ${url}`); + }); + const result = await gofile.probe("https://store1.gofile.io/download/web/u/game.zip"); + eq(result.ok, true, "store file resolves"); + eq(result.headers.cookie, "accountToken=guest-1", "store transfer carries a cookie"); + } + eq(registry.pluginFor("https://gofile.io/d/AbCdEfGh")?.id, "gofile", "routed"); + eq(registry.pluginFor("https://store1.gofile.io/download/web/u/game.zip")?.id, "gofile", "picked file re-probes"); + ok(registry.supportedHostIds().includes("gofile"), "offered as a mirror"); + { + // Fresh guest: the quota block must not depend on probe-block leftovers. + gofile.saltStore.resetGuest(); + stub(async (url) => { + if (String(url).includes("/accounts/")) { + return jsonResponse({ status: "ok", data: { tier: "guest", ipTraffic: {} } }); + } + return jsonResponse({ status: "ok", data: { token: "guest-1", id: "account-1" } }); + }); + const quota = await gofile.getQuota(); + eq(quota.ok, true, "free usage readable"); + eq(quota.cap, 1000000000000, "free allowance is 1 TB"); + } + } finally { + global.fetch = realFetch; + } + console.log(`Host plugin checks passed (${checks} assertions)`); })().catch((err) => { console.error(err); diff --git a/src/components/downloads/UpdateModal.jsx b/src/components/downloads/UpdateModal.jsx index aa55783b..fdd7691a 100644 --- a/src/components/downloads/UpdateModal.jsx +++ b/src/components/downloads/UpdateModal.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { Fragment, useCallback, useEffect, useState } from 'react' import HostIcon from './HostIcon.jsx' import { buildThreadUrl, threadUrlForGame } from './threadUrl.js' import { buildDownloadOptions } from './linkSections.js' @@ -34,12 +34,22 @@ import { buildDownloadOptions } from './linkSections.js' const prettyHost = (host) => String(host || '').replace(/^www\./, '') +const formatBytes = (value) => { + const bytes = Number(value) || 0 + if (bytes <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))) + const scaled = bytes / 1024 ** index + return `${scaled.toFixed(index === 0 ? 0 : scaled >= 10 ? 1 : 2)} ${units[index]}` +} + export default function UpdateModal({ game, open, onClose, onQueued, session = null }) { const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [errorCode, setErrorCode] = useState('') const [data, setData] = useState(null) const [resolvingUrl, setResolvingUrl] = useState('') + const [folderChoices, setFolderChoices] = useState(null) const threadId = game?.f95_id || game?.f95Id || null const title = game?.title || 'this game' @@ -81,26 +91,13 @@ export default function UpdateModal({ game, open, onClose, onQueued, session = n useEffect(() => { if (open) load(false) - else { setData(null); setError(''); setResolvingUrl('') } + else { setData(null); setError(''); setResolvingUrl(''); setFolderChoices(null) } }, [open, load]) // Resolving opens a real browser window where the user clears F95's gate // themselves. Atlas reads the destination and queues it. - const choose = async (link) => { - setResolvingUrl(link.url) - setError('') - try { - const resolved = await window.electronAPI.downloadsResolveMasked?.({ - url: link.url, - title, - }) - if (!resolved?.ok) { - if (!resolved?.canceled) { - setError(resolved?.error || 'Could not get the download link') - } - return - } - const queued = await window.electronAPI.downloadsEnqueue?.({ + const queueDownload = async (link, url, host) => { + const queued = await window.electronAPI.downloadsEnqueue?.({ // Every browse row already knows whether it is in the library: // local_record_id is projected as localRecordId in all four branches of // the catalog union, resolved from the atlas / f95 / lewdcorner / steam @@ -125,8 +122,8 @@ export default function UpdateModal({ game, open, onClose, onQueued, session = n title, creator: game?.creator || '', version: game?.latestVersion || game?.latest_version || '', - url: resolved.url, - host: resolved.host || link.host, + url, + host, source: 'f95', // Which build this is, in the poster's own words. The queue otherwise // shows the game title and the LATEST version on every row, so an old @@ -144,6 +141,7 @@ export default function UpdateModal({ game, open, onClose, onQueued, session = n onComplete: 'replace', }) if (queued?.success) { + setFolderChoices(null) onQueued?.(queued.item) // In a session the parent advances to the next game, which unmounts // this content anyway. Closing here as well would race that and leave @@ -152,6 +150,49 @@ export default function UpdateModal({ game, open, onClose, onQueued, session = n } else { setError(queued?.error || 'Could not add this to the download queue') } + } + + const choose = async (link) => { + setResolvingUrl(link.url) + setError('') + setFolderChoices(null) + try { + const resolved = await window.electronAPI.downloadsResolveMasked?.({ + url: link.url, + title, + }) + if (!resolved?.ok) { + if (!resolved?.canceled) { + setError(resolved?.error || 'Could not get the download link') + } + return + } + // The listing decides: choices present means pick, anything else queues. + // Hosts with no plugin fall through to the queue instead of erroring. + const folder = await window.electronAPI.downloadsListFolder?.({ url: resolved.url })?.catch(() => null) + if (folder?.ok && folder.choices?.length) { + setFolderChoices({ linkUrl: link.url, link, resolved, choices: folder.choices }) + return + } + if (folder && !folder.ok && !/no plugin/i.test(folder.error || '')) { + setError(folder.error || 'Could not read this folder') + return + } + await queueDownload(link, folder?.directUrl || resolved.url, resolved.host || link.host) + } catch (err) { + setError(err.message || 'Could not start this download') + } finally { + setResolvingUrl('') + } + } + + const chooseFile = async (choice) => { + const picked = folderChoices + if (!picked) return + setResolvingUrl(picked.linkUrl) + setError('') + try { + await queueDownload(picked.link, choice.directUrl, picked.resolved.host || picked.link.host) } catch (err) { setError(err.message || 'Could not start this download') } finally { @@ -376,8 +417,8 @@ export default function UpdateModal({ game, open, onClose, onQueued, session = n {option.links.map((link) => { const busy = resolvingUrl === link.url return ( + + {folderChoices?.linkUrl === link.url && ( +
+ {folderChoices.choices.map((choice) => ( + + ))} +
+ )} +
) })} diff --git a/src/components/settings/DownloadAccounts.jsx b/src/components/settings/DownloadAccounts.jsx index 2cff7591..ddba75ff 100644 --- a/src/components/settings/DownloadAccounts.jsx +++ b/src/components/settings/DownloadAccounts.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import HostIcon from '../downloads/HostIcon.jsx' // ── Settings: Download Accounts ────────────────────────────────────────────── @@ -30,6 +30,24 @@ function formatBytes(value) { return `${scaled.toFixed(index === 0 ? 0 : 1)} ${units[index]}` } +// Quota renders decimal to match the host's own site. +function formatQuotaBytes(value) { + const bytes = Number(value) || 0 + if (bytes <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1000))) + const scaled = bytes / 1000 ** index + return `${scaled.toFixed(index === 0 ? 0 : 1)} ${units[index]}` +} +function formatQuota(plugin, quota) { + const used = formatQuotaBytes(quota.used) + const cap = quota.cap != null ? formatQuotaBytes(quota.cap) : null + if (cap != null && plugin.quotaTemplate) { + return plugin.quotaTemplate.replace('{used}', used).replace('{cap}', cap) + } + return cap != null ? `Transfer used ${used} of ${cap}` : `Transfer used ${used}` +} + // A machine with SHA-NI hashes tens of gigabytes per second across all threads, // so MB/s stops being readable well before the top of the range. function formatThroughput(mbPerSecond) { @@ -53,6 +71,9 @@ function HostCard({ plugin, account, available, onSaved, onRemoved }) { const [error, setError] = useState('') const [notice, setNotice] = useState('') const [quota, setQuota] = useState(null) + const [infoMsg, setInfoMsg] = useState('') + const infoTimer = useRef(null) + useEffect(() => () => clearTimeout(infoTimer.current), []) // A centred modal rather than a panel expanding inside the card, matching the // site accounts above (Accounts.jsx / AddAccountModal). Signing in to a host // is the same kind of act as signing in to a site, and MEGA's form is three @@ -69,7 +90,8 @@ function HostCard({ plugin, account, available, onSaved, onRemoved }) { const hasAccount = Boolean(account) const loadQuota = useCallback(async () => { - if (!hasAccount) return + // Gofile reports free usage with no login, so its card loads either way. + if (!hasAccount && !plugin.quotaWithoutAccount) return try { const result = await window.electronAPI.hostsQuota?.({ hostId: plugin.id }) if (result?.ok) setQuota(result) @@ -77,7 +99,7 @@ function HostCard({ plugin, account, available, onSaved, onRemoved }) { // A quota readout is informational; failing to get one is not an error // worth showing. } - }, [plugin.id, hasAccount]) + }, [plugin.id, plugin.quotaWithoutAccount, hasAccount]) useEffect(() => { loadQuota() }, [loadQuota]) @@ -163,8 +185,7 @@ function HostCard({ plugin, account, available, onSaved, onRemoved }) {

{quota?.ok && (quota.cap != null || quota.used != null) && (

- Transfer used {formatBytes(quota.used)} - {quota.cap != null && ` of ${formatBytes(quota.cap)}`} + {formatQuota(plugin, quota)}

)} @@ -190,6 +211,21 @@ function HostCard({ plugin, account, available, onSaved, onRemoved }) { {hasAccount ? 'Replace' : 'Add account'} )} + {fields.length === 0 && !hasAccount && ( + + + + )} @@ -256,6 +292,7 @@ function HostCard({ plugin, account, available, onSaved, onRemoved }) { {/* The modal owns the error while it is open, and a failed save keeps it open, so the card only reports the outcome that closed it. */} {notice &&

{notice}

} + {infoMsg &&

{infoMsg}

} {modalOpen && (
{ fireEvent.click(button) } +const gofileCard = { + id: 'gofile', + label: 'Gofile', + supportsAnonymous: true, + quotaWithoutAccount: true, + quotaTemplate: 'Transfer used: {used} of {cap} per 30 days', + hasAccount: false, + credentialFields: [], +} + +const mockGofileCard = () => { + window.electronAPI.hostsList = vi.fn().mockResolvedValue({ + ok: true, available: true, plugins: [gofileCard], accounts: [], + }) +} + describe('DownloadAccounts host form', () => { it('shows no form until the button is pressed', async () => { render() @@ -127,4 +143,60 @@ describe('DownloadAccounts host form', () => { expect(await screen.findByText('MEGA rejected that password.')).toBeTruthy() expect(screen.getByLabelText(/Email/)).toBeTruthy() }) + + it('shows an Add account that explains login is unsupported', async () => { + mockGofileCard() + // 4.4 GB used of a 1 TB allowance, decimal units like gofile.io. + window.electronAPI.hostsQuota = vi.fn().mockResolvedValue({ + ok: true, used: 4400000000, cap: 1000000000000, + }) + render() + expect(await screen.findByText('Transfer used: 4.4 GB of 1.0 TB per 30 days')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Add account' })) + expect(await screen.findByText('Account login is not supported for this host yet')).toBeTruthy() + }) + + it('dismisses the unsupported-login message after a few seconds', async () => { + mockGofileCard() + window.electronAPI.hostsQuota = vi.fn().mockResolvedValue({ ok: false }) + render() + const addButton = await screen.findByRole('button', { name: 'Add account' }) + vi.useFakeTimers() + try { + fireEvent.click(addButton) + expect(screen.getByText('Account login is not supported for this host yet')).toBeTruthy() + act(() => { vi.advanceTimersByTime(4000) }) + expect(screen.queryByText('Account login is not supported for this host yet')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('renders used-only quota when the host reports no cap or template', async () => { + window.electronAPI.hostsList = vi.fn().mockResolvedValue({ + ok: true, + available: true, + plugins: [{ + id: 'gofile', + label: 'Gofile', + supportsAnonymous: true, + quotaWithoutAccount: true, + hasAccount: false, + credentialFields: [], + }], + accounts: [], + }) + window.electronAPI.hostsQuota = vi.fn().mockResolvedValue({ ok: true, used: 4400000000, cap: null }) + render() + expect(await screen.findByText('Transfer used 4.4 GB')).toBeTruthy() + }) + + it('does not fetch quota for a login host with no account', async () => { + render() + // The default plugins fixture is mega without an account: quota stays + // unfetched rather than failing against a missing session. + await screen.findByRole('button', { name: 'Add account' }) + await waitFor(() => expect(window.electronAPI.hostsList).toHaveBeenCalled()) + expect(window.electronAPI.hostsQuota).not.toHaveBeenCalled() + }) }) diff --git a/tests/download-manager-folders.test.js b/tests/download-manager-folders.test.js new file mode 100644 index 00000000..7896fd7e --- /dev/null +++ b/tests/download-manager-folders.test.js @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import Module from 'node:module' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +// The queue's safety net for a folder URL that reaches it unpicked: fail +// fatal with "pick one", never silently first-file. Runs the real manager +// against a temp database with fetch stubbed (electron stubbed too). + +process.env.ATLAS_GOFILE_PAUSE_MS = '0' + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-dm-folders-')) + +const electronStub = { + ipcMain: { handle: () => {} }, + shell: {}, + BrowserWindow: { getAllWindows: () => [] }, + app: { getPath: () => dataDir }, + dialog: {}, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (s) => Buffer.from(String(s)), + decryptString: (b) => String(b), + }, +} + +const originalLoad = Module._load +Module._load = function (request, ...rest) { + if (request === 'electron') return electronStub + return originalLoad.call(this, request, ...rest) +} + +const dbIndex = require('../electron/db/index.js') +const downloadsDb = require('../electron/db/downloads.js') +const manager = require('../electron/downloads/downloadManager.js') +const gofile = require('../electron/downloads/hosts/gofile.js') + +Module._load = originalLoad + +const json = (body, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}) + +const realFetch = globalThis.fetch +afterEach(() => { + globalThis.fetch = realFetch + delete process.env.ATLAS_USER_DATA + gofile.saltStore.resetGuest() +}) +afterAll(() => { + try { + fs.rmSync(dataDir, { recursive: true, force: true }) + } catch { + // Best-effort temp cleanup only. + } +}) + +const waitForState = async (id, want, tries = 100) => { + for (let i = 0; i < tries; i += 1) { + const item = await downloadsDb.getDownload(id) + if (item?.state === want) return item + await new Promise((r) => setTimeout(r, 50)) + } + throw new Error(`download ${id} never reached ${want}`) +} + +describe('download manager folder guard', () => { + beforeAll(async () => { + dbIndex.initializeDatabase(dataDir) + await downloadsDb.initializeDownloads() + manager.configure({ + onEvent: () => {}, + resolveDownloadsDir: () => dataDir, + resolveHostCredentials: () => ({}), + }) + }) + + it('fails a multi-file folder with a pick-one message, never first-files it', async () => { + globalThis.fetch = async (url) => { + const text = String(url) + if (text.includes('wt.obf.js')) { + return { ok: true, status: 200, text: async () => 'salt="bb22cc33dd44ee"' } + } + if (text.includes('/accounts')) { + return json({ status: 'ok', data: { token: 'guest-token', id: 'account-1' } }) + } + if (text.includes('/contents/')) { + return json({ + status: 'ok', + data: { + children: { + a: { id: 'u1', type: 'file', name: 'part1.zip', size: 10, link: 'https://store1.gofile.io/1' }, + b: { id: 'u2', type: 'file', name: 'part2.zip', size: 20, link: 'https://store1.gofile.io/2' }, + }, + }, + }) + } + throw new Error(`unstubbed fetch: ${text}`) + } + const queued = await manager.enqueue({ + title: 'Season 2', + url: 'https://gofile.io/d/AbCdEfGh', + host: 'gofile', + }) + expect(queued.success).toBe(true) + const item = await waitForState(queued.id, 'failed') + expect(item.error).toMatch(/2 files\. Pick one/i) + }) +}) diff --git a/tests/downloads-list-folder.test.js b/tests/downloads-list-folder.test.js new file mode 100644 index 00000000..57b88629 --- /dev/null +++ b/tests/downloads-list-folder.test.js @@ -0,0 +1,141 @@ +import { describe, it, expect, afterEach } from 'vitest' +import Module from 'node:module' +import path from 'node:path' + +const gofile = require('../electron/downloads/hosts/gofile.js') + +// The list-folder handler is thin glue, but it owns the modal's whole +// contract: choices for the picker vs a single-file directUrl that skips a +// second probe. Tested through the real registered handler: electron is +// stubbed, the plugin registry is real, only fetch is faked. +// +// Rotation pauses 4s before trying candidates (guest-budget discipline); +// tests exercise logic, not timing. +process.env.ATLAS_GOFILE_PAUSE_MS = '0' + +const handlers = new Map() +const electronStub = { + ipcMain: { handle: (channel, fn) => handlers.set(channel, fn) }, + shell: {}, + BrowserWindow: { getAllWindows: () => [] }, + app: { getPath: () => 'C:\\tmp\\atlas-test' }, + dialog: {}, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (s) => Buffer.from(String(s)), + decryptString: (b) => String(b), + }, +} + +const originalLoad = Module._load +Module._load = function (request, ...rest) { + if (request === 'electron') return electronStub + return originalLoad.call(this, request, ...rest) +} +require('../electron/ipc/downloads.js')({}) +Module._load = originalLoad + +const listFolder = (args) => handlers.get('downloads-list-folder')({}, args) + +const realFetch = globalThis.fetch +afterEach(() => { + globalThis.fetch = realFetch + delete process.env.ATLAS_USER_DATA + gofile.saltStore.resetGuest() +}) + +const json = (body, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}) +const guestAccount = () => json({ status: 'ok', data: { token: 'guest-token', id: 'account-1' } }) +const child = (over = {}) => ({ + id: 'u1', type: 'file', name: 'game.zip', size: 42, + link: 'https://store1.gofile.io/download/web/u1/game.zip', + ...over, +}) +const folder = (children) => json({ status: 'ok', data: { children } }) + +function stubFetch(routes) { + globalThis.fetch = async (url) => { + const text = String(url) + if (text.includes('wt.obf.js')) { + return { ok: true, status: 200, text: async () => 'salt="bb22cc33dd44ee"' } + } + const ordered = [...routes].sort((a, b) => b[0].length - a[0].length) + for (const [match, response] of ordered) { + if (text.includes(match)) return response + } + throw new Error(`unstubbed fetch: ${text}`) + } +} + +describe('downloads-list-folder', () => { + it('is registered on the real ipc module', () => { + expect(handlers.has('downloads-list-folder')).toBe(true) + }) + + it('refuses a missing url without touching the network', async () => { + let fetched = false + globalThis.fetch = async () => { fetched = true; throw new Error('must not fetch') } + expect(await listFolder()).toEqual({ ok: false, error: 'No URL supplied' }) + expect(await listFolder({})).toEqual({ ok: false, error: 'No URL supplied' }) + expect(fetched).toBe(false) + }) + + it('names the missing plugin instead of probing blindly', async () => { + expect(await listFolder({ url: 'https://example.com/file.zip' })) + .toEqual({ ok: false, error: 'No plugin for this host' }) + }) + + it('passes a probe failure through with its message', async () => { + stubFetch([ + ['/accounts', guestAccount()], + ['/contents/', json({ status: 'error-notFound' }, 404)], + ]) + const result = await listFolder({ url: 'https://gofile.io/d/deadbeef' }) + expect(result.ok).toBe(false) + expect(result.error).toContain('error-notFound') + }) + + it('returns choices for a multi-file folder', async () => { + stubFetch([ + ['/accounts', guestAccount()], + ['/contents/', folder({ + a: child({ name: 'part1.zip', size: 10, link: 'https://store1.gofile.io/1' }), + b: child({ id: 'u2', name: 'part2.zip', size: 20, link: 'https://store1.gofile.io/2' }), + })], + ]) + const result = await listFolder({ url: 'https://gofile.io/d/AbCdEfGh' }) + expect(result.ok).toBe(true) + expect(result.choices).toHaveLength(2) + expect(result.directUrl).toBeUndefined() + }) + + it('passes a single file through with one listing call, not two', async () => { + // The modal queues this directUrl without re-probing: every extra + // listing spends from the 20/min guest budget. + let listings = 0 + globalThis.fetch = async (url) => { + const text = String(url) + if (text.includes('wt.obf.js')) { + return { ok: true, status: 200, text: async () => 'salt="bb22cc33dd44ee"' } + } + if (text.includes('/accounts')) return guestAccount() + if (text.includes('/contents/')) { + listings += 1 + return folder({ a: child() }) + } + throw new Error(`unstubbed fetch: ${text}`) + } + const result = await listFolder({ url: 'https://gofile.io/d/AbCdEfGh' }) + expect(result).toEqual({ + ok: true, + directUrl: 'https://store1.gofile.io/download/web/u1/game.zip', + fileName: 'game.zip', + fileSize: 42, + }) + expect(listings).toBe(1) + }) +}) diff --git a/tests/gofile.test.js b/tests/gofile.test.js new file mode 100644 index 00000000..1d58d82a --- /dev/null +++ b/tests/gofile.test.js @@ -0,0 +1,570 @@ +import { describe, it, expect, afterEach } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const gofile = require('../electron/downloads/hosts/gofile.js') + +// Rotation pauses 4s before trying candidates (guest-budget discipline); +// tests exercise logic, not timing. +process.env.ATLAS_GOFILE_PAUSE_MS = '0' + +// Gofile shares are folders, and folders need a listing before anything can be +// queued. These pin the listing contract with fetch stubbed: which URLs the +// plugin claims, what a one-file folder resolves to, and what a multi-file +// folder returns for the modal picker. + +const realFetch = globalThis.fetch +const tempDirs = [] +afterEach(() => { + globalThis.fetch = realFetch + delete process.env.ATLAS_USER_DATA + gofile.saltStore.resetGuest() + while (tempDirs.length) { + try { + fs.rmSync(tempDirs.pop(), { recursive: true, force: true }) + } catch { + // Best-effort temp cleanup only. + } + } +}) + +const json = (body, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}) + +function stubFetch(routes) { + globalThis.fetch = async (url) => { + const text = String(url) + // Seedless probes fetch the live top seed first; answer it deterministically. + if (text.includes('wt.obf.js')) { + return { ok: true, status: 200, text: async () => 'salt="bb22cc33dd44ee"' } + } + // Longest match wins, so '/accounts' can't shadow '/accounts/account-1' + // regardless of route order. + const ordered = [...routes].sort((a, b) => b[0].length - a[0].length) + for (const [match, response] of ordered) { + if (text.includes(match)) return response + } + throw new Error(`unstubbed fetch: ${text}`) + } +} + +const guestAccount = () => json({ status: 'ok', data: { token: 'guest-token', id: 'account-1' } }) +const child = (over = {}) => ({ + id: 'u1', type: 'file', name: 'game.zip', size: 42, + link: 'https://store1.gofile.io/download/web/u1/game.zip', + ...over, +}) +const folder = (children) => json({ status: 'ok', data: { children } }) + +describe('matches', () => { + it('claims share pages', () => { + expect(gofile.matches('https://gofile.io/d/AbCdEfGh')).toBe(true) + expect(gofile.matches('https://www.gofile.io/d/AbCdEfGh')).toBe(true) + }) + + it('claims store hosts so picked files re-probe with a cookie', () => { + expect(gofile.matches('https://store1.gofile.io/download/web/u1/game.zip')).toBe(true) + expect(gofile.matches('https://srv-store2.gofile.io/download/web/u1/game.zip')).toBe(true) + }) + + it('does not claim other hosts or garbage', () => { + expect(gofile.matches('https://pixeldrain.com/u/x')).toBe(false) + expect(gofile.matches('not a url')).toBe(false) + }) +}) + +describe('fileIdFrom', () => { + it('reads the share code', () => { + expect(gofile.fileIdFrom('https://gofile.io/d/AbCdEfGh')).toBe('AbCdEfGh') + expect(gofile.fileIdFrom('https://gofile.io/?c=AbCd12')).toBe('AbCd12') + }) + + it('returns null when there is no code', () => { + expect(gofile.fileIdFrom('https://gofile.io/')).toBeNull() + }) +}) + +describe('websiteToken', () => { + it('is deterministic for the same input', () => { + const now = 1725897600000 + expect(gofile.websiteToken('tok', now)).toBe(gofile.websiteToken('tok', now)) + expect(gofile.websiteToken('tok', now)).toMatch(/^[0-9a-f]{64}$/) + }) + + it('changes across windows', () => { + const now = 1725897600000 + expect(gofile.websiteToken('tok', now)).not.toBe(gofile.websiteToken('tok', now + 14400 * 1000)) + }) +}) + +describe('classifyError', () => { + it('treats a bare 404 as fatal', () => { + expect(gofile.classifyError(null, { status: 404, body: null })).toBe('fatal') + }) + + it('treats a rejected token as auth', () => { + expect(gofile.classifyError(null, { body: { status: 'error-wrongToken' } })).toBe('auth') + }) + + it('treats a rejected signature as transient', () => { + expect(gofile.classifyError(null, { status: 401, body: { status: 'error-notPremium' } })).toBe('transient') + }) + + it('treats throttling as quota, never fatal', () => { + // Load-bearing for the rotation stop: a quota verdict ends the loop, + // anything else keeps burning budget. + expect(gofile.classifyError(null, { status: 429, body: null })).toBe('quota') + expect(gofile.classifyError(null, { body: { status: 'error-rateLimit' } })).toBe('quota') + expect(gofile.classifyError(null, { body: { status: 'error-limits' } })).toBe('quota') + }) +}) + +describe('extractSalts', () => { + it('ranks the live \\x-escaped salt first among obfuscator junk', () => { + const { extractSalts } = gofile.saltStore + const script = 'a="W71SCxpdL8kLFmklW34eF" k=\'\\x48\\x77\\x45\\x6e\\x6b\':\'\\x31\\x32\\x61\\x66\\x30' + + '\\x35\\x36\\x64\\x61\\x63\\x65\\x61\\x30\\x62\' b="junk0000000001" c="junk0000000002"' + expect(extractSalts(script)[0]).toBe('12af056dacea0b') + }) +}) + +describe('probe', () => { + it('resolves a one-file folder to a direct url', async () => { + stubFetch([ + ['/accounts', guestAccount()], + ['/contents/', folder({ a: child() })], + ]) + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(true) + expect(result.directUrl).toBe('https://store1.gofile.io/download/web/u1/game.zip') + expect(result.fileName).toBe('game.zip') + expect(result.fileSize).toBe(42) + expect(result.headers.cookie).toBe('accountToken=guest-token') + }) + + it('probes a store file link with a fresh guest cookie', async () => { + stubFetch([ + ['/accounts', guestAccount()], + ]) + const result = await gofile.probe('https://store1.gofile.io/download/web/u1/game.zip') + expect(result.ok).toBe(true) + expect(result.directUrl).toBe('https://store1.gofile.io/download/web/u1/game.zip') + expect(result.headers.cookie).toBe('accountToken=guest-token') + }) + + it('returns choices for a multi-file folder, not a direct url', async () => { + stubFetch([ + ['/accounts', guestAccount()], + ['/contents/', folder({ + a: child({ name: 'part1.zip', size: 10, link: 'https://store1.gofile.io/1' }), + b: child({ id: 'u2', name: 'part2.zip', size: 20, link: 'https://store1.gofile.io/2' }), + })], + ]) + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(true) + expect(result.directUrl).toBeUndefined() + expect(result.choices).toHaveLength(2) + expect(result.choices[0]).toEqual({ name: 'part1.zip', size: 10, directUrl: 'https://store1.gofile.io/1' }) + }) + + it('fails a missing folder without retrying', async () => { + stubFetch([ + ['/accounts', guestAccount()], + ['/contents/', json({ status: 'error-notFound' }, 404)], + ]) + const result = await gofile.probe('https://gofile.io/d/deadbeef') + expect(result.ok).toBe(false) + expect(result.kind).toBe('fatal') + expect(result.error).toContain('error-notFound') + }) + + it('pairs a throttled verdict with wait advice when recovery finds nothing', async () => { + // Stored salt rejected, script unreachable: the original 429 surfaces + // with its meaning attached, not bare. + const dir = useTempStore() + fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: 'bogus00000000' })) + globalThis.fetch = async (url) => { + const text = String(url) + if (text.includes('/accounts')) return guestAccount() + if (text.includes('/contents/')) return json({ status: 'error-rateLimit' }, 429) + throw new Error(`unstubbed fetch: ${text}`) + } + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(false) + expect(result.error).toContain('error-rateLimit') + expect(result.error).toMatch(/wait 1-2 mins/i) + }) + + it('passes unknown API codes through with no invented advice', async () => { + stubFetch([ + ['/accounts', guestAccount()], + ['/contents/', json({ status: 'error-teapot' }, 400)], + ]) + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(false) + expect(result.error).toBe('Gofile returned error-teapot') + }) + + it('refuses password-protected folders', async () => { + stubFetch([ + ['/accounts', guestAccount()], + ['/contents/', json({ status: 'error-passwordRequired' }, 403)], + ]) + const result = await gofile.probe('https://gofile.io/d/locked12') + expect(result.ok).toBe(false) + expect(result.kind).toBe('fatal') + expect(result.error).toMatch(/password/i) + }) + + it('names Atlas as the limitation on an unrecognised link', async () => { + const result = await gofile.probe('https://gofile.io/pricing') + expect(result.ok).toBe(false) + expect(result.error).toMatch(/atlas/i) + }) + + it('calls an empty folder empty or expired, not a failure', async () => { + stubFetch([ + ['/accounts', guestAccount()], + ['/contents/', folder({})], + ]) + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(false) + expect(result.kind).toBe('fatal') + expect(result.error).toMatch(/empty or expired/i) + }) + + it('refuses a subfolders-only folder instead of first-filing it', async () => { + stubFetch([ + ['/accounts', guestAccount()], + ['/contents/', folder({ a: { id: 'u1', type: 'folder', name: 'sub', link: 'https://gofile.io/d/subfold1' } })], + ]) + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(false) + expect(result.kind).toBe('fatal') + expect(result.error).toMatch(/only subfolders/i) + }) + + it('reports a store link it cannot mint a guest session for', async () => { + globalThis.fetch = async (url) => { + if (String(url).includes('/accounts')) throw new Error('connection reset') + throw new Error(`unstubbed fetch: ${url}`) + } + const result = await gofile.probe('https://store1.gofile.io/download/web/u1/game.zip') + expect(result.ok).toBe(false) + expect(result.error).toMatch(/could not reach gofile/i) + }) + + it('reports seed failure as transient when the script is unreachable', async () => { + // Fresh install, no stored salt, script host down: nothing validated, + // nothing saved, retryable. + const dir = useTempStore() + globalThis.fetch = async (url) => { + if (String(url).includes('/accounts')) return guestAccount() + throw new Error('connection reset') + } + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(false) + expect(result.kind).toBe('transient') + expect(result.error).toMatch(/changed something|report/i) + expect(fs.existsSync(path.join(dir, 'gofile-salt.json'))).toBe(false) + }) +}) + +const useTempStore = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-gofile-')) + tempDirs.push(dir) + process.env.ATLAS_USER_DATA = dir + return dir +} + +const storedSalt = (dir) => + JSON.parse(fs.readFileSync(path.join(dir, 'gofile-salt.json'), 'utf8')).salt + +// Probe against a contents stub that only accepts one salt, so the wrong +// salt deterministically yields error-notPremium. +const stubSaltedListing = (goodSalt, scriptText) => { + globalThis.fetch = async (url, init) => { + const text = String(url) + if (text.includes('wt.obf.js')) { + if (scriptText == null) throw new Error('script must not be fetched') + return { ok: true, status: 200, text: async () => scriptText } + } + if (text.includes('/accounts')) return guestAccount() + if (text.includes('/contents/')) { + const sent = init?.headers?.['x-website-token'] + const now = Date.now() + const good = [ + gofile.websiteToken('guest-token', now, goodSalt), + gofile.websiteToken('guest-token', now - 14400 * 1000, goodSalt), + ] + if (good.includes(sent)) return folder({ a: child() }) + return json({ status: 'error-notPremium' }, 401) + } + throw new Error(`unstubbed fetch: ${text}`) + } +} + +describe('salt persistence', () => { + it('uses the stored salt first without fetching the script', async () => { + const dir = useTempStore() + const STORED = 'bb22cc33dd44ee' + fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: STORED })) + stubSaltedListing(STORED, null) + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(true) + expect(result.directUrl).toBe('https://store1.gofile.io/download/web/u1/game.zip') + }) + + it('replaces the stored salt after a rotation', async () => { + const dir = useTempStore() + fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: 'oldSalt00000000' })) + const NEW_SALT = 'cc33dd44ee55ff' + stubSaltedListing(NEW_SALT, `salt="${NEW_SALT}"`) + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(true) + expect(storedSalt(dir)).toBe(NEW_SALT) + }) + + it('recovers when a bad hash surfaces as rateLimit, not notPremium', async () => { + // A bogus seed must self-heal: rateLimit triggers the live refetch too. + const dir = useTempStore() + fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: 'bogus00000000' })) + const REAL = 'aa11bb22cc33dd' + let scriptFetched = false + globalThis.fetch = async (url, init) => { + const text = String(url) + if (text.includes('wt.obf.js')) { + scriptFetched = true + return { ok: true, status: 200, text: async () => `salt="${REAL}"` } + } + if (text.includes('/accounts')) return guestAccount() + if (text.includes('/contents/')) { + const sent = init?.headers?.['x-website-token'] + const now = Date.now() + if (sent === gofile.websiteToken('guest-token', now, REAL)) return folder({ a: child() }) + return json({ status: 'error-rateLimit' }, 429) + } + throw new Error(`unstubbed fetch: ${text}`) + } + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(scriptFetched).toBe(true) + expect(result.ok).toBe(true) + expect(storedSalt(dir)).toBe(REAL) + }) + + it('recovers from a bare HTTP 429 with no JSON body', async () => { + // Gofile sometimes answers a bad hash with a bodiless 429 rather than a + // status string. That must trigger the live refetch, not surface as-is. + const dir = useTempStore() + fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: 'bogus00000000' })) + const REAL = 'aa11bb22cc33dd' + globalThis.fetch = async (url, init) => { + const text = String(url) + if (text.includes('wt.obf.js')) { + return { ok: true, status: 200, text: async () => `salt="${REAL}"` } + } + if (text.includes('/accounts')) return guestAccount() + if (text.includes('/contents/')) { + const sent = init?.headers?.['x-website-token'] + const now = Date.now() + if (sent === gofile.websiteToken('guest-token', now, REAL)) return folder({ a: child() }) + return { ok: false, status: 429, json: async () => null } + } + throw new Error(`unstubbed fetch: ${text}`) + } + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(true) + expect(storedSalt(dir)).toBe(REAL) + }) + it('stops rotation at the first throttle instead of burning budget', async () => { + // A throttled candidate means the guest budget is gone: every further + // try extends the wall, so the loop ends, nothing is saved, and the + // original verdict is returned. Total contents calls: stored + prev + + // one candidate. + const dir = useTempStore() + fs.writeFileSync(path.join(dir, 'gofile-salt.json'), JSON.stringify({ salt: 'bogus00000000' })) + const JUNK = 'ff11ee22dd33cc' + const REAL = 'aa11bb22cc33dd' + let calls = 0 + globalThis.fetch = async (url, init) => { + const text = String(url) + if (text.includes('wt.obf.js')) { + return { ok: true, status: 200, text: async () => `a="${JUNK}" b="${REAL}"` } + } + if (text.includes('/accounts')) return guestAccount() + if (text.includes('/contents/')) { + calls += 1 + const sent = init?.headers?.['x-website-token'] + const now = Date.now() + if (sent === gofile.websiteToken('guest-token', now, REAL)) return folder({ a: child() }) + if (sent === gofile.websiteToken('guest-token', now, JUNK)) return json({ status: 'error-rateLimit' }, 429) + return json({ status: 'error-notPremium' }, 401) + } + throw new Error(`unstubbed fetch: ${text}`) + } + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(false) + expect(calls).toBe(3) + expect(storedSalt(dir)).toBe('bogus00000000') + }) + + it('leaves no stored salt when live recovery fails', async () => { + const dir = useTempStore() + globalThis.fetch = async (url) => { + const text = String(url) + if (text.includes('/accounts')) return guestAccount() + if (text.includes('/contents/')) return json({ status: 'error-notPremium' }, 401) + throw new Error(`unstubbed fetch: ${text}`) + } + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(false) + expect(fs.existsSync(path.join(dir, 'gofile-salt.json'))).toBe(false) + }) + + it('tolerates a corrupt store file', async () => { + const dir = useTempStore() + fs.writeFileSync(path.join(dir, 'gofile-salt.json'), 'not json{{{') + const NEW_SALT = 'dd44ee55ff66gg' + stubSaltedListing(NEW_SALT, `salt="${NEW_SALT}"`) + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(true) + expect(storedSalt(dir)).toBe(NEW_SALT) + }) + + it('spends one listing call per junk candidate, not two', async () => { + // Seedless probe: live top seed first (hex-ranked, so the real salt), + // then one listing call. The old per-candidate double-window retry + // spent two per junk entry. + useTempStore() + const REAL = 'ee55ff66aa77bb' + let listings = 0 + globalThis.fetch = async (url, init) => { + const text = String(url) + if (text.includes('wt.obf.js')) { + return { ok: true, status: 200, text: async () => 'a="junk0000000001" b="junk0000000002" c="ee55ff66aa77bb"' } + } + if (text.includes('/accounts')) return guestAccount() + if (text.includes('/contents/')) { + listings += 1 + const sent = init?.headers?.['x-website-token'] + const now = Date.now() + const good = [ + gofile.websiteToken('guest-token', now, REAL), + gofile.websiteToken('guest-token', now - 14400 * 1000, REAL), + ] + if (good.includes(sent)) return folder({ a: child() }) + return json({ status: 'error-notPremium' }, 401) + } + throw new Error(`unstubbed fetch: ${text}`) + } + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(true) + // Seed fetch (script, uncounted) + one listing with the top seed. + // No second-window pass: the hit landed on the current window. + expect(listings).toBe(1) + }) + + it('mints a fresh token when the cached one is rejected', async () => { + // Session token cache: a wrongToken verdict (IP change, expiry) drops + // it, mints once, and retries — then succeeds instead of staying dead. + const dir = useTempStore() + gofile.saltStore.resetGuest() + const GOOD = 'aa11bb22cc33dd' + let accounts = 0 + globalThis.fetch = async (url, init) => { + const text = String(url) + if (text.includes('wt.obf.js')) { + return { ok: true, status: 200, text: async () => `salt="${GOOD}"` } + } + if (text.includes('/accounts')) { + accounts += 1 + return json({ status: 'ok', data: { token: accounts === 1 ? 'stale-1' : 'fresh-2', id: 'account-1' } }) + } + if (text.includes('/contents/')) { + // Rotation point is the token, not the hash (covered elsewhere). + if (init?.headers?.authorization === 'Bearer fresh-2') return folder({ a: child() }) + return json({ status: 'error-wrongToken' }, 401) + } + throw new Error(`unstubbed fetch: ${text}`) + } + const result = await gofile.probe('https://gofile.io/d/AbCdEfGh') + expect(result.ok).toBe(true) + expect(accounts).toBe(2) + expect(storedSalt(dir)).toBe(GOOD) + }) + + it('shares one script fetch across concurrent refreshes', async () => { + const saltStore = gofile.saltStore + let scripts = 0 + globalThis.fetch = async () => { + scripts += 1 + return { ok: true, status: 200, text: async () => 'salt="ee55ff66aa77bb"' } + } + const [a, b] = await Promise.all([saltStore.refresh(), saltStore.refresh()]) + expect(scripts).toBe(1) + expect(a).toEqual(b) + }) +}) + +describe('validate', () => { + it('is always anonymous', async () => { + expect(await gofile.validate({})).toEqual({ ok: true, anonymous: true }) + }) +}) + +describe('getQuota', () => { + const details = (data) => json({ status: 'ok', data: { id: 'account-1', tier: 'guest', ...data } }) + const quotaRoutes = (detailResp) => [ + ['/accounts/account-1', detailResp], + ['/accounts', guestAccount()], + ] + + it('sums recent usage against the free allowance', async () => { + const d = new Date() + stubFetch(quotaRoutes(details({ ipTraffic: { + 2020: { 1: { 1: 999 } }, + [d.getUTCFullYear()]: { [d.getUTCMonth() + 1]: { [d.getUTCDate()]: 100 } }, + } }))) + const quota = await gofile.getQuota() + expect(quota.ok).toBe(true) + expect(quota.used).toBe(100) + expect(quota.cap).toBe(1000000000000) + }) + + it('reports no cap for non-guest tiers', async () => { + stubFetch(quotaRoutes(details({ tier: 'premium', ipTraffic: {} }))) + const quota = await gofile.getQuota() + expect(quota.ok).toBe(true) + expect(quota.used).toBe(0) + expect(quota.cap).toBeNull() + }) + + it('fails instead of guessing when usage is missing', async () => { + stubFetch(quotaRoutes(details({}))) + expect((await gofile.getQuota()).ok).toBe(false) + }) + + it('reads string usage buckets instead of reporting zero', async () => { + const d = new Date() + stubFetch(quotaRoutes(details({ ipTraffic: { + [d.getUTCFullYear()]: { [d.getUTCMonth() + 1]: { [d.getUTCDate()]: '100' } }, + } }))) + const quota = await gofile.getQuota() + expect(quota.ok).toBe(true) + expect(quota.used).toBe(100) + }) + + it('sums nested hourly leaves instead of skipping them', async () => { + const d = new Date() + stubFetch(quotaRoutes(details({ ipTraffic: { + [d.getUTCFullYear()]: { [d.getUTCMonth() + 1]: { [d.getUTCDate()]: { 0: 40, 12: 60 } } }, + } }))) + const quota = await gofile.getQuota() + expect(quota.ok).toBe(true) + expect(quota.used).toBe(100) + }) +}) diff --git a/tests/update-modal-options.test.jsx b/tests/update-modal-options.test.jsx index 500eb192..b0245885 100644 --- a/tests/update-modal-options.test.jsx +++ b/tests/update-modal-options.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react' import UpdateModal from '../src/components/downloads/UpdateModal.jsx' @@ -101,4 +101,107 @@ describe('UpdateModal build options', () => { mount([link('mega.nz', '', 'Win'), link('mega.nz', 'Season 1', 'Win')]) await waitFor(() => { expect(screen.getByText('Full Archive')).toBeTruthy() }) }) + + it('expands a multi-file gofile folder into a picker and queues the picked file', async () => { + mount([link('gofile.io', 'Season 2', 'Win')]) + window.electronAPI.downloadsResolveMasked.mockResolvedValue({ + ok: true, url: 'https://gofile.io/d/AbCdEfGh', host: 'gofile.io', + }) + window.electronAPI.downloadsListFolder = vi.fn().mockResolvedValue({ + ok: true, + choices: [ + { name: 'part1.zip', size: 10, directUrl: 'https://store1.gofile.io/1' }, + { name: 'part2.zip', size: 20, directUrl: 'https://store1.gofile.io/2' }, + ], + }) + window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} }) + await waitFor(() => { expect(screen.getByText('gofile.io')).toBeTruthy() }) + fireEvent.click(screen.getByText('gofile.io')) + await waitFor(() => { expect(screen.getByText('part1.zip')).toBeTruthy() }) + expect(screen.getByText('10 B')).toBeTruthy() + fireEvent.click(screen.getByText('part2.zip')) + await waitFor(() => { expect(window.electronAPI.downloadsEnqueue).toHaveBeenCalled() }) + expect(window.electronAPI.downloadsEnqueue.mock.calls[0][0].url).toBe('https://store1.gofile.io/2') + }) + + it('queues a direct file link when no plugin claims it instead of refusing', async () => { + mount([link('gofile.io', 'Season 2', 'Win')]) + window.electronAPI.downloadsResolveMasked.mockResolvedValue({ + ok: true, url: 'https://store1.gofile.io/download/web/u1/game.zip', host: 'store1.gofile.io', + }) + window.electronAPI.downloadsListFolder = vi.fn().mockResolvedValue({ + ok: false, error: 'No plugin for this host', + }) + window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} }) + await waitFor(() => { expect(screen.getByText('gofile.io')).toBeTruthy() }) + fireEvent.click(screen.getByText('gofile.io')) + await waitFor(() => { expect(window.electronAPI.downloadsEnqueue).toHaveBeenCalled() }) + expect(window.electronAPI.downloadsEnqueue.mock.calls[0][0].url).toBe('https://store1.gofile.io/download/web/u1/game.zip') + }) + + it('shows the picker for any host whose listing returns choices', async () => { + mount([link('buzzheavier.com', 'Season 2', 'Win')]) + window.electronAPI.downloadsResolveMasked.mockResolvedValue({ + ok: true, url: 'https://buzzheavier.com/f/AbCd12', host: 'buzzheavier.com', + }) + window.electronAPI.downloadsListFolder = vi.fn().mockResolvedValue({ + ok: true, + choices: [ + { name: 'a.zip', size: 1, directUrl: 'https://buzzheavier.com/d/a' }, + ], + }) + window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} }) + await waitFor(() => { expect(screen.getByText('buzzheavier.com')).toBeTruthy() }) + fireEvent.click(screen.getByText('buzzheavier.com')) + await waitFor(() => { expect(screen.getByText('a.zip')).toBeTruthy() }) + expect(window.electronAPI.downloadsEnqueue).not.toHaveBeenCalled() + }) + + it('shows a failed folder listing instead of queueing blindly', async () => { + mount([link('gofile.io', 'Season 2', 'Win')]) + window.electronAPI.downloadsResolveMasked.mockResolvedValue({ + ok: true, url: 'https://gofile.io/d/AbCdEfGh', host: 'gofile.io', + }) + window.electronAPI.downloadsListFolder = vi.fn().mockResolvedValue({ + ok: false, error: 'Gofile returned error-teapot', + }) + window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} }) + await waitFor(() => { expect(screen.getByText('gofile.io')).toBeTruthy() }) + fireEvent.click(screen.getByText('gofile.io')) + await waitFor(() => { expect(screen.getByText('Gofile returned error-teapot')).toBeTruthy() }) + expect(window.electronAPI.downloadsEnqueue).not.toHaveBeenCalled() + }) + + it('queues a single-file listing directly with no picker', async () => { + mount([link('gofile.io', 'Season 2', 'Win')]) + window.electronAPI.downloadsResolveMasked.mockResolvedValue({ + ok: true, url: 'https://gofile.io/d/AbCdEfGh', host: 'gofile.io', + }) + window.electronAPI.downloadsListFolder = vi.fn().mockResolvedValue({ + ok: true, directUrl: 'https://store1.gofile.io/download/web/u1/game.zip', + fileName: 'game.zip', fileSize: 42, + }) + window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} }) + await waitFor(() => { expect(screen.getByText('gofile.io')).toBeTruthy() }) + fireEvent.click(screen.getByText('gofile.io')) + await waitFor(() => { expect(window.electronAPI.downloadsEnqueue).toHaveBeenCalled() }) + // The share URL, never the listing: no second probe, no picker. + expect(window.electronAPI.downloadsEnqueue.mock.calls[0][0].url) + .toBe('https://store1.gofile.io/download/web/u1/game.zip') + }) + + it('falls back to the resolved url when the listing api is missing', async () => { + mount([link('gofile.io', 'Season 2', 'Win')]) + window.electronAPI.downloadsResolveMasked.mockResolvedValue({ + ok: true, url: 'https://gofile.io/d/AbCdEfGh', host: 'gofile.io', + }) + // Older preload without downloadsListFolder: optional call short-circuits + // to undefined instead of throwing into the error path. + delete window.electronAPI.downloadsListFolder + window.electronAPI.downloadsEnqueue.mockResolvedValue({ success: true, item: {} }) + await waitFor(() => { expect(screen.getByText('gofile.io')).toBeTruthy() }) + fireEvent.click(screen.getByText('gofile.io')) + await waitFor(() => { expect(window.electronAPI.downloadsEnqueue).toHaveBeenCalled() }) + expect(window.electronAPI.downloadsEnqueue.mock.calls[0][0].url).toBe('https://gofile.io/d/AbCdEfGh') + }) }) From 60365c1f042041bdad626e4113da2b275c256463 Mon Sep 17 00:00:00 2001 From: Codeon <313085171+codeon89@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:25:09 -0700 Subject: [PATCH 2/2] PATCHED: CHANGELOG #408 --- CHANGELOG.PATCHED.md | 3 +++ CHANGELOG.md | 1 - PATCHES.md | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.PATCHED.md b/CHANGELOG.PATCHED.md index 227bff2f..0e180ac7 100644 --- a/CHANGELOG.PATCHED.md +++ b/CHANGELOG.PATCHED.md @@ -1,5 +1,8 @@ # CHANGELOG - PATCHED +**v0.9.9-patched.nightly.494.5** + - Add Gofile support. One-file folders queue directly; multi-file folders show a picker. Settings shows free usage (1 TB / 30 days), no login needed. The site token salt is cached locally and re-fetched automatically when Gofile rotates it, so links keep working without updates; guests share one session and one 4s-paced retry per incident to stay inside the ~20 calls/min free budget, with a wait-and-retry message when throttled. [#408](https://github.com/towerwatchman/Atlas/pull/408) + **v0.9.9-patched.nightly.494.4** - Downloads: clicking a cover now opens the game entry inside Atlas Library/ Catalog, and clicking the build label (e.g. Full Archive) opens the source thread in your browser. [#406](https://github.com/towerwatchman/Atlas/pull/406) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb812b60..0b7a4439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ - Path resolution highlighting: red if invalid, green if path exists or pass the check. ### Added -- Add Gofile support. One-file folders queue directly; multi-file folders show a picker. Settings shows free usage (1 TB / 30 days), no login needed. The site token salt is cached locally and re-fetched automatically when Gofile rotates it, so links keep working without updates; guests share one session and one 4s-paced retry per incident to stay inside the ~20 calls/min free budget, with a wait-and-retry message when throttled. - Custom media uploads in the Game Details Media tab: add preview images from local files, a drag-and-drop zone, or an image URL, with live progress. Previews can be reordered by drag and the order persists in a new `preview_sort` table keyed by remote URL (or relative path for custom uploads), so it survives re-downloads, stream/download switches and metadata refreshes. Previews now carry a source logo and a storage-location badge, and custom previews can be deleted independently of downloaded ones. - Add Buzzheavier host support (`buzzheavier.com`, `bzzhr.to`, `bzzhr.co`). The download route is behind a Cloudflare challenge, so the resolve runs in a browser window: it clicks the htmx download button, captures the `HX-Redirect` (or an attachment via `will-download`), and hands the resolved CDN link plus the browser's own cookies/UA back to the downloader. The resolve partition is persistent so a solved challenge survives a restart -- only Cloudflare's own challenge cookies are kept, everything else is stripped after each resolve -- and resolves run one at a time, since a shared session cannot carry two concurrently. Each time your IP changes there is a brief auto-resolve window while the challenge is re-solved. - The version readout in the topnav and sidebar is now a button that opens that version's GitHub release page. The tag it builds matches what the release workflows publish -- `v` for stable and `v-nightly.` for nightly -- so it lands on the real release rather than a 404. (#143) diff --git a/PATCHES.md b/PATCHES.md index 9ec90c76..0fe60c51 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -2,6 +2,7 @@ ## Pending Patched Changes *Changes that's already on the fork and waiting to be reviewed for merge into original Atlas* + - Add Gofile support. One-file folders queue directly; multi-file folders show a picker. Settings shows free usage (1 TB / 30 days), no login needed. The site token salt is cached locally and re-fetched automatically when Gofile rotates it, so links keep working without updates; guests share one session and one 4s-paced retry per incident to stay inside the ~20 calls/min free budget, with a wait-and-retry message when throttled. [#408](https://github.com/towerwatchman/Atlas/pull/408) - Downloads: clicking a cover now opens the game entry inside Atlas Library/ Catalog, and clicking the build label (e.g. Full Archive) opens the source thread in your browser. [#406](https://github.com/towerwatchman/Atlas/pull/406) - Importer and Library folder scheme now support `{atlasId}`, so installs can be matched back to AtlasDB on re-import / rebuild.[#404](https://github.com/towerwatchman/Atlas/pull/404) - Removed the stale restart popup and hint on the Show debug console toggle — it applies immediately to all open windows.[#399](https://github.com/towerwatchman/Atlas/pull/399)