Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ This part is unchanged from the original project. Follow @masterking32's guide o
- Who has access: **Anyone**
6. Copy the **Deployment ID** (the long random string in the URL).

> **Alternative backend — Apps Script + Cloudflare Worker.** A variant in [`assets/apps_script/Code.cfw.gs`](assets/apps_script/Code.cfw.gs) + [`assets/cloudflare/worker.js`](assets/cloudflare/worker.js) turns Apps Script into a thin forwarder and offloads the actual fetch to a Cloudflare Worker you deploy. The win on day one is **latency** (~10-50 ms at the CF edge vs ~250-500 ms in Apps Script — visibly snappier for browsing and Telegram). It does **not** reduce your daily 20k Apps Script `UrlFetchApp` count, because today's mhrv-rs always sends single-URL relay requests; the batch path on the GAS+Worker side is wired and ready (`ceil(N/40)` quota per N-URL batch) but no shipping client emits it. Trade-offs: worse for YouTube long-form (30 s wall vs 6 min), no fix for Cloudflare anti-bot, **not compatible with `mode: "full"`** (no tunnel-ops support → won't help WhatsApp/messengers on Android full mode). Full setup and trade-off table in [`assets/cloudflare/README.md`](assets/cloudflare/README.md). mhrv-rs needs no config changes — same `mode: "apps_script"`, same `script_id`, same `auth_key`.

#### Can't reach `script.google.com` from your network?

If your ISP is already blocking Google Apps Script (or all of Google), you need Step 1's browser connection to succeed *before* you have a relay to use. `mhrv-rs` ships a `direct` mode for exactly this — SNI-rewrite tunnel only, no Apps Script relay required. (Was named `google_only` before v1.9 — the old name is still accepted in config files.)
Expand Down Expand Up @@ -499,6 +501,10 @@ Donations cover hosting, self-hosted CI runner costs, and continued maintenance.

> **نکته:** اگر نمی‌دانید رمز `AUTH_KEY` چه بگذارید، یک رشتهٔ تصادفی ۱۶ تا ۲۴ کاراکتری بسازید. مهم فقط این است که **دقیقاً همان رشته** را در برنامه هم وارد کنید.

<!-- -->

> **پشتیبان جایگزین — `Apps Script` + `Cloudflare Worker`.** نسخه‌ای در [`assets/apps_script/Code.cfw.gs`](assets/apps_script/Code.cfw.gs) به‌همراه [`assets/cloudflare/worker.js`](assets/cloudflare/worker.js) وجود دارد که `Apps Script` را به یک رلهٔ نازک تبدیل می‌کند و کار `fetch` واقعی را به یک `Cloudflare Worker` که خودتان مستقر می‌کنید می‌سپارد. سود روز اول این کار **کاهش تأخیر** است (~۱۰ تا ۵۰ میلی‌ثانیه روی لبهٔ `CF` به جای ۲۵۰ تا ۵۰۰ میلی‌ثانیه روی `Apps Script` — برای مرور وب و تلگرام محسوس). سهمیهٔ روزانهٔ `UrlFetchApp` (~۲۰٬۰۰۰) را کاهش **نمی‌دهد**، چون امروز `mhrv-rs` همیشه درخواست تک‌آدرسی می‌فرستد؛ مسیر دسته‌ای روی `GAS+Worker` آماده و سیم‌کشی شده (`ceil(N/40)` سهمیه به‌ازای دستهٔ `N` آدرسی) ولی هیچ کلاینتی فعلاً آن را تولید نمی‌کند. مبادلات: ویدیوی طولانی یوتیوب بدتر می‌شود (دیوار ۳۰ ثانیه به جای ۶ دقیقه)، ضدبات `Cloudflare` را حل نمی‌کند، و **با `mode: "full"` سازگار نیست** (پشتیبانی از عملیات تونل ندارد → برای واتس‌اَپ و سایر مسنجرها روی اندرویدِ تونل کامل کمکی نمی‌کند). راهنمای کامل استقرار و جدول مبادلات در [`assets/cloudflare/README.fa.md`](assets/cloudflare/README.fa.md). در `mhrv-rs` هیچ تنظیمی تغییر نمی‌کند — همان `mode: "apps_script"`، همان `script_id`، همان `auth_key`.

#### به `script.google.com` هم دسترسی ندارید؟

اگر `ISP` شما از قبل `Apps Script` (یا کل گوگل) را مسدود کرده، برای مرحلهٔ ۱ باید مرورگرتان **اول** به `script.google.com` برسد — قبل از اینکه رله‌ای داشته باشید. `mhrv-rs` یک حالت `direct` دقیقاً برای همین دارد — فقط تونل بازنویسی `SNI`، بدون نیاز به رلهٔ `Apps Script`. (قبل از v1.9 این حالت `google_only` نام داشت — نام قدیمی همچنان در فایل کانفیگ پذیرفته می‌شود.)
Expand Down
360 changes: 360 additions & 0 deletions assets/apps_script/Code.cfw.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,360 @@
/**
* DomainFront Relay — Apps Script with Cloudflare Worker exit.
*
* Variant of Code.gs that off-loads the actual outbound HTTP fetch to
* a Cloudflare Worker. Apps Script becomes a thin auth-and-forward
* relay; Cloudflare does the work and pays the latency.
*
* mhrv-rs ──► Apps Script (this file) ──► Cloudflare Worker ──► target
* ▲ inbound auth & batch ▲ outbound fetch + base64
*
* Wire protocol with mhrv-rs is identical to Code.gs:
* 1. Single: POST { k, m, u, h, b, ct, r } → { s, h, b }
* 2. Batch: POST { k, q: [{m,u,h,b,ct,r}, ...] } → { q: [{s,h,b}, ...] }
* Both shapes are forwarded to the Worker as one POST per call
* from Apps Script: single mode posts {k, u, m, ...} once, batch
* mode posts {k, q: [...]} once. The Worker fans out batches
* internally via Promise.all. This is the design choice that
* makes Code.cfw.gs actually save GAS UrlFetchApp quota — without
* it we'd have to fetchAll(N worker calls) and end up at parity
* with the standard Code.gs.
*
* Trade-off summary (read before deploying):
* + Per-call latency drops from ~250-500 ms (Apps Script internal
* hop) to ~10-50 ms (CF edge). Visibly snappier for chat-style
* workloads (Telegram, page navigation).
* + Apps Script *runtime* quota (90 min/day on consumer accounts)
* stretches significantly because each call now spends almost all
* its time in the network leg to the Worker, not in the body
* fetch + base64 + header processing.
* + Apps Script *UrlFetchApp count* quota stretches roughly Nx for
* an N-URL batch because the batch is sent as a small number of
* POSTs to the Worker (one per chunk of WORKER_BATCH_CHUNK URLs),
* not fanned out per-URL via fetchAll. For mhrv-rs's typical
* 5-30 URL batches that's 1 GAS call (vs N under standard
* Code.gs). Single non-batched requests still count 1:1.
* - YouTube long-form streaming gets WORSE. Apps Script allows
* ~6 min wall per execution; CF Workers cap at 30 s wall. The
* SABR cliff hits sooner. For YouTube-heavy use, keep the
* standard Code.gs (apps_script mode).
* - Batch mode now has a per-batch wall, not per-URL: Promise.all
* resolves only when every fetch finishes, so the slowest URL
* dominates. mhrv-rs already retries failed batch items
* individually, so failure modes are graceful, but it's a real
* behavioural change vs Code.gs's per-URL fetchAll wall.
* - Cloudflare anti-bot challenges on destination sites can be
* stricter — exit IP is now in CF's own range, which CF's
* anti-bot fingerprints as a worker-internal request. This is
* a different problem than DPI bypass; not solved by either
* variant.
*
* Deployment:
* 1. Deploy assets/cloudflare/worker.js to Cloudflare Workers first
* (set its AUTH_KEY to a strong secret).
* 2. Note the *.workers.dev URL of that Worker.
* 3. Open https://script.google.com → New project, delete default code.
* 4. Paste THIS entire file.
* 5. Set AUTH_KEY (must match the Worker's AUTH_KEY and your mhrv-rs
* config's auth_key — all three identical).
* 6. Set WORKER_URL to your *.workers.dev URL (must include https://).
* 7. Deploy → New deployment → Web app
* Execute as: Me | Who has access: Anyone
* 8. Copy the Deployment ID into mhrv-rs config.json as "script_id".
* mhrv-rs does not need to know about Cloudflare; it talks to
* Apps Script the same way it always has.
*
* CHANGE THESE TWO CONSTANTS BELOW.
*
* Upstream credit for the GAS-→-Worker pattern: github.com/denuitt1/mhr-cfw.
* This file inherits the hardening (decoy-on-bad-auth, hop-loop guard)
* from the standard Code.gs.
*/

const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";

// Full https://… URL of the Cloudflare Worker you deployed using
// assets/cloudflare/worker.js. Must include the scheme.
const WORKER_URL = "https://CHANGE_ME.workers.dev";

// ── Sentinels — DO NOT EDIT ─────────────────────────────────
// These two constants are NOT configuration. They are the literal
// template-default values used by the fail-closed check in doPost so
// that a forgotten edit (AUTH_KEY or WORKER_URL still set to the
// placeholder) returns a loud error instead of silently accepting the
// placeholder secret or POSTing to a bogus URL. Configure AUTH_KEY
// and WORKER_URL above; leave these alone.
const DEFAULT_AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
const DEFAULT_WORKER_URL = "https://CHANGE_ME.workers.dev";

// Must match the Worker's MAX_BATCH_SIZE. Batches larger than this
// are split into chunks of this size and dispatched via fetchAll —
// each chunk costs 1 GAS UrlFetchApp call, so an N-URL batch costs
// ceil(N/CHUNK) calls (still much cheaper than the per-URL cost
// under standard Code.gs's fetchAll).
const WORKER_BATCH_CHUNK = 40;

// Active-probing defense — same semantics as Code.gs. Bad-auth and
// malformed POST bodies receive a decoy HTML page that looks like a
// placeholder Apps Script web app instead of the JSON `{e}` error,
// so probes can't fingerprint the deployment as a relay endpoint.
// Flip to `true` only during initial setup if you need to debug an
// "unauthorized" loop, then flip back before sharing the deployment.
const DIAGNOSTIC_MODE = false;

const SKIP_HEADERS = {
host: 1, connection: 1, "content-length": 1,
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
"priority": 1, te: 1,
};

const DECOY_HTML =
'<!DOCTYPE html><html><head><title>Web App</title></head>' +
'<body><p>The script completed but did not return anything.</p>' +
'</body></html>';

// ── Request Handlers ────────────────────────────────────────

function _decoyOrError(jsonBody) {
if (DIAGNOSTIC_MODE) return _json(jsonBody);
return ContentService
.createTextOutput(DECOY_HTML)
.setMimeType(ContentService.MimeType.HTML);
}

function doPost(e) {
try {
// Fail-closed if either constant is still the template default.
// Without this, a forgotten edit would either accept the placeholder
// secret as valid auth or POST to a literal "CHANGE_ME" URL — both
// are silent failure modes a deploy might miss. Surface them loud.
if (AUTH_KEY === DEFAULT_AUTH_KEY) {
return _json({ e: "configure AUTH_KEY in Code.cfw.gs" });
}
if (WORKER_URL === DEFAULT_WORKER_URL) {
return _json({ e: "configure WORKER_URL in Code.cfw.gs" });
}

var req = JSON.parse(e.postData.contents);
if (req.k !== AUTH_KEY) return _decoyOrError({ e: "unauthorized" });

if (Array.isArray(req.q)) return _doBatch(req.q);
return _doSingle(req);
} catch (err) {
return _decoyOrError({ e: String(err) });
}
}

function doGet(e) {
return ContentService
.createTextOutput(DECOY_HTML)
.setMimeType(ContentService.MimeType.HTML);
}

// ── Worker Forwarding ──────────────────────────────────────

/**
* Strip headers that must not be forwarded (hop-by-hop / Apps-Script-
* managed). Returns a fresh header map; the input is never mutated.
*/
function _scrubHeaders(rawHeaders) {
var out = {};
if (rawHeaders && typeof rawHeaders === "object") {
for (var k in rawHeaders) {
if (rawHeaders.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) {
out[k] = rawHeaders[k];
}
}
}
return out;
}

/**
* Normalize one request item into the shape the Worker expects.
* Used for both single and batch paths — single mode wraps this in
* `{k, ...item}`; batch mode wraps it in `{k, q: [item, ...]}`.
* Auth key is added at envelope level by callers, not per-item.
*/
function _normalizeItem(item) {
return {
u: item.u,
m: (item.m || "GET").toUpperCase(),
h: _scrubHeaders(item.h),
b: item.b || null,
ct: item.ct || null,
r: item.r !== false,
};
}

function _workerFetchOptions(payload) {
return {
url: WORKER_URL,
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true,
followRedirects: true,
validateHttpsCertificates: true,
};
}

// ── Single Request ─────────────────────────────────────────

function _doSingle(req) {
if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) {
return _json({ e: "bad url" });
}

var item = _normalizeItem(req);
var envelope = {
k: AUTH_KEY,
u: item.u,
m: item.m,
h: item.h,
b: item.b,
ct: item.ct,
r: item.r,
};
var opts = _workerFetchOptions(envelope);
// muteHttpExceptions covers HTTP-level errors (4xx/5xx come back as
// a normal HTTPResponse). It does NOT cover network-level failures
// — DNS resolution failure, TLS handshake failure, connection
// timeout to *.workers.dev, etc. — those throw. Catch and surface
// them as `{e}` so the operator debugging "why isn't my deployment
// responding?" gets a useful signal instead of the doPost outer
// catch returning the decoy HTML page (which makes the deployment
// look like a bad-auth probe to the client). Auth has already
// passed at this point so the probe-defence argument doesn't apply.
var resp;
try {
resp = UrlFetchApp.fetch(opts.url, opts);
} catch (err) {
return _json({ e: "worker unreachable: " + String(err) });
}
return _json(_parseWorkerJson(resp));
}

// ── Batch Request ──────────────────────────────────────────

/**
* Forward a batch to the Worker, chunking when needed. Each chunk
* becomes ONE POST to the Worker; the Worker fans out across the URLs
* in the chunk via Promise.all and returns `{q: [...]}` in the same
* order. Multiple chunks fire in parallel via UrlFetchApp.fetchAll.
*
* Quota cost: ceil(N / WORKER_BATCH_CHUNK) GAS UrlFetchApp calls for
* an N-URL batch. For typical mhrv-rs batches of 5-30 URLs this is
* exactly 1 call (vs N under standard Code.gs's fetchAll). Larger
* batches gracefully degrade to a few calls instead of failing under
* the Worker's own MAX_BATCH_SIZE soft cap.
*
* Bad-URL items are filtered locally so the Worker only sees valid
* inputs, then re-interleaved into the result array in original order
* so mhrv-rs's batch-index assumptions hold.
*/
function _doBatch(items) {
var validItems = [];
var errorMap = {};

for (var i = 0; i < items.length; i++) {
var item = items[i];
if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
errorMap[i] = "bad url";
continue;
}
validItems.push(_normalizeItem(item));
}

var workerResults = [];
if (validItems.length > 0) {
// Split into chunks ≤ WORKER_BATCH_CHUNK so each Worker call stays
// under the Worker's MAX_BATCH_SIZE cap. Single-chunk fast path
// avoids the fetchAll overhead for the common case.
var chunks = [];
for (var c = 0; c < validItems.length; c += WORKER_BATCH_CHUNK) {
chunks.push(validItems.slice(c, c + WORKER_BATCH_CHUNK));
}

var fetchOpts = chunks.map(function(chunk) {
return _workerFetchOptions({ k: AUTH_KEY, q: chunk });
});

// muteHttpExceptions covers HTTP-level errors. Network-level
// failures (DNS, TLS, connection timeout to *.workers.dev) still
// throw — catch and convert to per-chunk `{e}` errors that get
// spread across each chunk's slots. mhrv-rs's per-item retry
// then handles them individually instead of getting the decoy
// HTML page from the doPost outer catch. See _doSingle for why
// the probe-defence argument doesn't apply post-auth.
var responses;
try {
if (fetchOpts.length === 1) {
responses = [UrlFetchApp.fetch(fetchOpts[0].url, fetchOpts[0])];
} else {
responses = UrlFetchApp.fetchAll(fetchOpts);
}
} catch (err) {
var unreachable = { e: "worker unreachable: " + String(err) };
for (var u = 0; u < validItems.length; u++) workerResults.push(unreachable);
// Skip the per-response loop below by returning early through the
// reassembly code path.
responses = null;
}

for (var r = 0; responses && r < responses.length; r++) {
var parsed = _parseWorkerJson(responses[r]);
if (parsed && Array.isArray(parsed.q)) {
for (var k = 0; k < parsed.q.length; k++) {
workerResults.push(parsed.q[k]);
}
} else {
// Per-chunk failure (worker error, parse failure, auth, etc).
// Spread the same error to every slot in this chunk so mhrv-rs
// retries each item individually rather than masking the
// failure. Other chunks are unaffected.
var slotErr = (parsed && parsed.e)
? { e: parsed.e }
: { e: "worker batch failure" };
for (var s = 0; s < chunks[r].length; s++) workerResults.push(slotErr);
}
}
}

// Reassemble into the original order: validated slots get their
// worker result; invalid slots get their pre-flight error.
var results = [];
var wi = 0;
for (var j = 0; j < items.length; j++) {
if (errorMap.hasOwnProperty(j)) {
results.push({ e: errorMap[j] });
} else {
results.push(workerResults[wi++] || { e: "missing worker response" });
}
}
return _json({ q: results });
}

// ── Worker response handling ───────────────────────────────

/**
* Parse the Worker's JSON envelope. Worker errors come back as
* `{e: "..."}` — pass them through to the client unchanged so mhrv-rs
* sees the same error-shape it would for a direct-fetch failure in
* Code.gs. On HTTP errors from the Worker itself (auth failure, 5xx,
* etc.), wrap into `{e}` so the client gets a useful message instead
* of a parse-failure.
*/
function _parseWorkerJson(resp) {
var code = resp.getResponseCode();
var text = resp.getContentText();
try {
return JSON.parse(text);
} catch (err) {
return { e: "worker " + code + ": " + (text.length > 200 ? text.substring(0, 200) + "…" : text) };
}
}

function _json(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
ContentService.MimeType.JSON
);
}
Loading
Loading