diff --git a/assets/apps_script/Code.gs b/assets/apps_script/Code.gs index f9d03755..6c3a63b0 100644 --- a/assets/apps_script/Code.gs +++ b/assets/apps_script/Code.gs @@ -2,36 +2,23 @@ * DomainFront Relay — Google Apps Script * * TWO modes: - * 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}, ...] } - * Uses UrlFetchApp.fetchAll() — all URLs fetched IN PARALLEL. - * - * OPTIONAL SPREADSHEET-BACKED RESPONSE CACHE: - * Set CACHE_SPREADSHEET_ID to a valid Google Sheet ID (must be owned by - * the same account). When enabled, public GET requests are stored in the - * sheet and served from there on repeat visits, reducing UrlFetchApp - * quota consumption. Bodies are gzipped before base64 storage so larger - * responses fit under the per-cell character limit, and persistent - * 4xx (404/410/451) get a short negative-cache TTL so buggy clients - * that hammer dead URLs cost zero quota; 5xx is never cached so a - * flapping upstream cannot poison a 24h slot with a transient outage. - * The cache is Vary-aware (Accept-Encoding and Accept-Language are - * hashed into the compound cache key). Leave CACHE_SPREADSHEET_ID as-is - * to disable caching entirely — zero overhead. + * 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}, ...] } + * Uses UrlFetchApp.fetchAll() — all URLs fetched IN PARALLEL. * * DEPLOYMENT: - * 1. Go to https://script.google.com → New project - * 2. Delete the default code, paste THIS entire file - * 3. Change AUTH_KEY below to your own secret - * 4. (Optional) Set CACHE_SPREADSHEET_ID to enable caching - * 5. Click Deploy → New deployment - * 6. Type: Web app | Execute as: Me | Who has access: Anyone - * 7. Copy the Deployment ID into config.toml as "script_id" + * 1. Go to script.google.com → New project + * 2. Delete the default code, paste THIS entire file + * 3. Open Project Settings (⚙️ icon) > Script Properties + * 4. Add key-value pair: AUTH_KEY = + * 5. Click Deploy → New deployment + * 6. Type: Web app | Execute as: Me | Who has access: Anyone + * 7. Copy the Deployment ID into config.toml (previous config.json) as "script_id" * - * CHANGE THE AUTH KEY BELOW TO YOUR OWN SECRET! */ +const PROPERTIES = PropertiesService.getScriptProperties(); -const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET"; +const AUTH_KEY = PROPERTIES.getProperty("AUTH_KEY"); // Active-probing defense. When false (production default), bad AUTH_KEY // requests get a decoy HTML page that looks like a placeholder Apps @@ -46,42 +33,6 @@ const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET"; // (Inspired by #365 Section 3, mhrv-rs v1.8.0+.) const DIAGNOSTIC_MODE = false; -// ── Optional Spreadsheet Cache ────────────────────────────── -// Set to a valid Spreadsheet ID to enable response caching. -// Leave as-is to disable caching entirely (zero overhead). -const CACHE_SPREADSHEET_ID = "CHANGE_ME_TO_CACHE_SPREADSHEET_ID"; -const CACHE_SHEET_NAME = "RelayCache"; -const CACHE_META_SHEET_NAME = "RelayMeta"; -const CACHE_META_CURSOR_CELL = "A1"; - -// ── Cache Tuning ──────────────────────────────────────────── -const CACHE_MAX_ROWS = 5000; // circular buffer capacity -const CACHE_MAX_BODY_BYTES = 35000; // skip responses larger than ~35 KB -const CACHE_DEFAULT_TTL_SECONDS = 86400; // 24-hour fallback when no Cache-Control - -// ── Negative Caching ──────────────────────────────────────── -// Persistent 4xx errors get a short TTL when the upstream is silent on -// Cache-Control. Buggy clients hammer dead URLs (favicons, telemetry -// pixels, dev-tools probes); a 5-minute floor absorbs the storm at -// zero quota cost while letting transient 404s self-heal quickly. -// 5xx is never cached — see _fetchAndCache. -const NEGATIVE_CACHE_STATUSES = { 404: 1, 410: 1, 451: 1 }; -const NEGATIVE_CACHE_TTL_SECONDS = 300; - -// ── Body Compression ──────────────────────────────────────── -// Bodies are gzipped before base64 storage when worthwhile. Gzip has -// ~20 bytes of header overhead, so very small payloads can bloat; -// skip below this threshold. Already-encoded responses (gzip/br/etc.) -// are stored as-is to avoid double-compression. -const GZIP_MIN_BYTES = 256; - -// ── Vary-Aware Cache Key ──────────────────────────────────── -// These request headers are hashed into the compound cache key -// alongside the URL so that responses with different encodings -// or languages never collide in the cache. Covers ~95 % of -// real-world Vary usage without inspecting the response. -const VARY_KEY_HEADERS = ["accept-encoding", "accept-language"]; - // Connection-level + IP-leak request headers we strip before forwarding // to the destination. Browser capability headers (sec-ch-ua*, sec-fetch-*) // stay intact — modern apps like Google Meet use them for browser gating. @@ -89,25 +40,19 @@ const VARY_KEY_HEADERS = ["accept-encoding", "accept-language"]; // misconfigured upstream proxy on the user side can't leak the user's // real IP through the relay path. Mirrors upstream // `masterking32/MasterHttpRelayVPN@3094288`. -const SKIP_HEADERS = { - host: 1, connection: 1, "content-length": 1, - "transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1, - "priority": 1, te: 1, - "x-forwarded-for": 1, "x-forwarded-host": 1, "x-forwarded-proto": 1, - "x-forwarded-port": 1, "x-real-ip": 1, "forwarded": 1, "via": 1, -}; - +const SKIP_HEADERS = new Set([ + "host", "connection", "content-length", + "transfer-encoding", "proxy-connection", "proxy-authorization", + "priority", "te", + "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", + "x-forwarded-port", "x-real-ip", "forwarded", "via", +]); +const VALID_METHODS = new Set([ "get", "post", "put", "delete", "patch", "head", "options" ]); // Methods we consider safe to replay if `UrlFetchApp.fetchAll()` raises. // GET/HEAD/OPTIONS are idempotent per RFC 9110; POST/PUT/PATCH/DELETE // can have side-effects so we surface the error instead of silently // re-firing them. -const SAFE_REPLAY_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1 }; - -// Headers that disqualify a request from the cache path. -const CACHE_BUSTING_HEADERS = { - authorization: 1, cookie: 1, "x-api-key": 1, - "proxy-authorization": 1, "set-cookie": 1, -}; +const SAFE_REPLAY_METHODS = new Set([ "get", "head", "options" ]); // HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style // placeholder page — no proxy-shaped JSON, nothing distinctive enough @@ -117,682 +62,359 @@ const DECOY_HTML = '

The script completed but did not return anything.

' + ''; -// ── Request Handlers ──────────────────────────────────────── +const URL_PATTERN = /^https?:\/\//i; -function _decoyOrError(jsonBody) { - if (DIAGNOSTIC_MODE) return _json(jsonBody); - return ContentService - .createTextOutput(DECOY_HTML) - .setMimeType(ContentService.MimeType.HTML); -} +/** + * @typedef {Object} ClientRequest + * @property {GoogleAppsScript.URL_Fetch.HttpMethod} m + * @property {string} u - URL to relay + * @property {GoogleAppsScript.URL_Fetch.HttpHeaders} h + * @property {string} b - Request body + * @property {string} ct - Request contentType + * @property {boolean} r - Request goes through exit node + */ -function doPost(e) { - try { - var req = JSON.parse(e.postData.contents); - if (req.k !== AUTH_KEY) return _decoyOrError({ e: "unauthorized" }); +/** + * @typedef {Object} ClientRequestSingle + * @property {string} k - Auth token + * @property {GoogleAppsScript.URL_Fetch.HttpMethod} m + * @property {string} u - URL to relay + * @property {GoogleAppsScript.URL_Fetch.HttpHeaders} h + * @property {string} b - Request body + * @property {string} ct - Request contentType + * @property {boolean} r - Request goes through exit node + */ - // Batch mode: { k, q: [...] } - if (Array.isArray(req.q)) return _doBatch(req.q); +/** + * @typedef {Object} ClientRequestBatch + * @property {string} k + * @property {Array} q + */ - // Single mode - return _doSingle(req); - } catch (err) { - // Parse failures of the request body are also probe-shaped — a real - // mhrv-rs client never sends invalid JSON. Decoy for the same reason. - return _decoyOrError({ e: String(err) }); - } -} +/** + * @typedef RelayErrorResponse + * @property {string} e + */ + +/** + * @typedef RelaySingleResponse + * @property {number} s + * @property {GoogleAppsScript.URL_Fetch.HttpHeaders} h + * @property {string} b +*/ + +/** + * @typedef RelayBatchResponse + * @property {Array} q + */ -// `doGet` is what active scanners hit first (HTTP GET probes are cheaper -// than POSTs). Apps Script defaults to a "Script function not found" page -// here which is a fine-enough decoy on its own, but explicitly returning -// the same harmless placeholder makes the response identical to the -// bad-auth POST decoy — one less fingerprint vector. -function doGet(e) { +/** + * `doGet` is what active scanners hit first (HTTP GET probes are cheaper + * than POSTs). Apps Script defaults to a "Script function not found" page + * here which is a fine-enough decoy on its own, but explicitly returning + * the same harmless placeholder makes the response identical to the + * bad-auth POST decoy — one less fingerprint vector. + * @param {GoogleAppsScript.Events.DoGet} e +*/ +function doGet (e) { return ContentService .createTextOutput(DECOY_HTML) - .setMimeType(ContentService.MimeType.HTML); + .setMimeType(ContentService.MimeType.XML); } -// ── Single Request ───────────────────────────────────────── - -function _doSingle(req) { - if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) { - return _json({ e: "bad url" }); - } - - // ── Optional cache path ──────────────────────────────── - // Only entered when CACHE_SPREADSHEET_ID is configured and - // the request qualifies as a public, cachable GET. - if (_canUseCache(req)) { - var cached = _getFromCache(req.u, req.h); - if (cached) { - return _json({ - s: cached.status, - h: JSON.parse(cached.headers), - b: cached.body, - cached: true, - }); - } - - var fetchResult = _fetchAndCache(req.u, req.h); - if (fetchResult) { - return _json({ - s: fetchResult.status, - h: JSON.parse(fetchResult.headers), - b: fetchResult.body, - cached: false, - }); - } - // If _fetchAndCache returns null (spreadsheet unavailable), - // fall through to the normal relay path below. - } - - // ── Normal relay (cache disabled or unavailable) ──────── - // Wrap the fetch + body encode in try/catch so any failure surfaces as - // a JSON error envelope the Rust client can parse. Without this, throws - // from UrlFetchApp.fetch (URL too long, payload too large, quota - // exhausted, 6-minute execution timeout) or from base64Encode (response - // body near Apps Script's ~50 MB ceiling can blow the V8 heap during - // encode) propagate unhandled, and Apps Script serves its default - // `Web App` HTML error page — which the client then - // reports as "Relay failed: bad response: no json in: Web App>..." - // and the user has no signal as to the actual cause. Mirrors the - // per-item try/catch in _doBatch below. - try { - var opts = _buildOpts(req); - var resp = UrlFetchApp.fetch(req.u, opts); - - return _json({ - s: resp.getResponseCode(), - h: _respHeaders(resp), - b: Utilities.base64Encode(resp.getContent()), - }); - } catch (err) { - return _json({ e: "fetch failed: " + String(err) }); - } +/** @param {RelayErrorResponse | RelaySingleResponse | RelayBatchResponse} obj */ +function _relayResponse (obj) { + return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType( + ContentService.MimeType.JSON + ); } -// ── Batch Request ────────────────────────────────────────── - -function _doBatch(items) { - var fetchArgs = []; - var fetchIndex = []; - var fetchMethods = []; - var errorMap = {}; +/** @param {RelayErrorResponse} err */ +function _decoyOrError (err) { + if (DIAGNOSTIC_MODE) return _relayResponse(err); + return ContentService + .createTextOutput(DECOY_HTML) + .setMimeType(ContentService.MimeType.XML); +} - for (var i = 0; i < items.length; i++) { - var item = items[i]; - if (!item || typeof item !== "object") { - errorMap[i] = "bad item"; - continue; - } - if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) { - errorMap[i] = "bad url"; - continue; - } - try { - var opts = _buildOpts(item); - opts.url = item.u; - fetchArgs.push(opts); - fetchIndex.push(i); - fetchMethods.push(String(item.m || "GET").toUpperCase()); - } catch (buildErr) { - errorMap[i] = String(buildErr); - } - } +/** @param {GoogleAppsScript.URL_Fetch.HTTPResponse} resp */ +function _respHeaders (resp) { + return resp.getAllHeaders?.() ?? resp.getHeaders(); +} - // fetchAll() processes all requests in parallel inside Google. If it - // throws as a whole (e.g. one URL violates UrlFetchApp limits and - // poisons the whole batch), degrade to per-item fetch on safe methods - // so a single bad request does not zero out every response in the - // batch. Mirrors upstream `masterking32/MasterHttpRelayVPN@3094288`. - var responses = []; - if (fetchArgs.length > 0) { - try { - responses = UrlFetchApp.fetchAll(fetchArgs); - } catch (fetchAllErr) { - responses = []; - for (var j = 0; j < fetchArgs.length; j++) { - try { - if (!SAFE_REPLAY_METHODS[fetchMethods[j]]) { - errorMap[fetchIndex[j]] = - "batch fetchAll failed; unsafe method not replayed"; - responses[j] = null; - continue; - } - var fallbackReq = fetchArgs[j]; - var fallbackUrl = fallbackReq.url; - var fallbackOpts = {}; - for (var key in fallbackReq) { - if ( - Object.prototype.hasOwnProperty.call(fallbackReq, key) && - key !== "url" - ) { - fallbackOpts[key] = fallbackReq[key]; - } - } - responses[j] = UrlFetchApp.fetch(fallbackUrl, fallbackOpts); - } catch (singleErr) { - errorMap[fetchIndex[j]] = String(singleErr); - responses[j] = null; - } - } - } - } +/** @param {ClientRequest} req */ +function _buildOpts (req) { + const method = (req.m?.toLowerCase?.() ?? "get"); - var results = []; - var rIdx = 0; - for (var i = 0; i < items.length; i++) { - if (Object.prototype.hasOwnProperty.call(errorMap, i)) { - results.push({ e: errorMap[i] }); - } else { - var resp = responses[rIdx++]; - if (!resp) { - results.push({ e: "fetch failed" }); - } else { - results.push({ - s: resp.getResponseCode(), - h: _respHeaders(resp), - b: Utilities.base64Encode(resp.getContent()), - }); - } - } + if (!VALID_METHODS.has(method)) { + throw new Error(`Invalid HTTP method: ${ method }`); } - return _json({ q: results }); -} - -// ── Request Building ─────────────────────────────────────── -function _buildOpts(req) { - var opts = { - method: (req.m || "GET").toLowerCase(), + /** @type {GoogleAppsScript.URL_Fetch.URLFetchRequestOptions} */ + let opts = { + /** @type {GoogleAppsScript.URL_Fetch.HttpMethod} */ + method: method, muteHttpExceptions: true, - followRedirects: req.r !== false, + followRedirects: true, // ← always true; r flag now has different meaning validateHttpsCertificates: true, escaping: false, }; + if (req.h && typeof req.h === "object") { - var headers = {}; - for (var k in req.h) { - if (req.h.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) { - headers[k] = req.h[k]; - } - } - opts.headers = headers; + opts.headers = Object.fromEntries( + Object.entries(req.h).filter(([ k ]) => !SKIP_HEADERS.has(k.toLowerCase()))); } if (req.b) { - opts.payload = Utilities.base64Decode(req.b); - if (req.ct) opts.contentType = req.ct; - } - return opts; -} - -function _respHeaders(resp) { - try { - if (typeof resp.getAllHeaders === "function") { - return resp.getAllHeaders(); - } - } catch (err) {} - return resp.getHeaders(); -} - -function _json(obj) { - return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType( - ContentService.MimeType.JSON - ); -} - -// ═══════════════════════════════════════════════════════════ -// SPREADSHEET CACHE — SHEET MANAGEMENT -// ═══════════════════════════════════════════════════════════ - -function _initCacheSheet() { - if (CACHE_SPREADSHEET_ID === "CHANGE_ME_TO_CACHE_SPREADSHEET_ID") { - return null; - } - try { - var ss = SpreadsheetApp.openById(CACHE_SPREADSHEET_ID); - var sheet = ss.getSheetByName(CACHE_SHEET_NAME); - if (!sheet) { - sheet = ss.insertSheet(CACHE_SHEET_NAME); - // Schema: URL_Hash | URL | Status | Headers | Body | Timestamp | Expires_At | Z - // Z is 1 when Body is base64(gzip(rawBytes)), 0/empty when base64(rawBytes). - // Legacy 7-column rows from older deployments read back as Z=undefined, - // which the cache hit path treats as "not gzipped" — fully compatible. - sheet.getRange(1, 1, 1, 8).setValues([[ - "URL_Hash", "URL", "Status", "Headers", "Body", "Timestamp", "Expires_At", "Z" - ]]); + if (typeof req.b !== "string") { + throw new Error("Payload must be string (base64)"); } - return sheet; - } catch (e) { - return null; - } -} - -function _getMetaSheet() { - if (CACHE_SPREADSHEET_ID === "CHANGE_ME_TO_CACHE_SPREADSHEET_ID") { - return null; - } - try { - var ss = SpreadsheetApp.openById(CACHE_SPREADSHEET_ID); - var sheet = ss.getSheetByName(CACHE_META_SHEET_NAME); - if (!sheet) { - sheet = ss.insertSheet(CACHE_META_SHEET_NAME); - sheet.getRange(CACHE_META_CURSOR_CELL).setValue(2); - sheet.hideSheet(); + try { + const decoded = Utilities.base64Decode(req.b); + if (decoded.length > 50000000) { + throw new Error("Payload exceeds 50MB limit"); + } + opts.payload = decoded; + } catch (decodeErr) { + throw new Error(`Base64 decode failed: ${ String(decodeErr) }`); } - return sheet; - } catch (e) { - return null; - } -} - -function _getNextCursor(sheet, metaSheet) { - var cursorRange = metaSheet.getRange(CACHE_META_CURSOR_CELL); - var cursor = cursorRange.getValue(); - if (typeof cursor !== "number" || cursor < 2) cursor = 2; - - var totalRows = sheet.getDataRange().getNumRows(); - - if (totalRows < CACHE_MAX_ROWS + 1) { - return totalRows + 1; - } - - return cursor; -} - -function _advanceCursor(metaSheet, currentRow) { - var nextRow = currentRow + 1; - if (nextRow > CACHE_MAX_ROWS + 1) nextRow = 2; - metaSheet.getRange(CACHE_META_CURSOR_CELL).setValue(nextRow); -} - -function _ensureRowsAllocated(sheet) { - var totalRows = sheet.getDataRange().getNumRows(); - if (totalRows < CACHE_MAX_ROWS + 1) { - var needed = CACHE_MAX_ROWS + 1 - totalRows; - sheet.insertRowsAfter(totalRows, needed); + if (req.ct) opts.contentType = req.ct; } + return opts; } -// ═══════════════════════════════════════════════════════════ -// SPREADSHEET CACHE — VARY-AWARE COMPOUND KEY -// ═══════════════════════════════════════════════════════════ - /** - * Case-insensitive header lookup. - * HTTP header names are case-insensitive per RFC 7230 § 3.2. - */ -function _getHeaderCaseInsensitive(headers, targetKey) { - var target = targetKey.toLowerCase(); - for (var k in headers) { - if (headers.hasOwnProperty(k) && k.toLowerCase() === target) { - return headers[k]; + * @param {GoogleAppsScript.URL_Fetch.HTTPResponse} resp + * @return {RelaySingleResponse} +*/ +function _buildResponse (resp) { + try { + let respContentB64 = Utilities.base64Encode(resp.getContent()); + if (respContentB64.length > 50000000) { + throw new Error("Payload exceeds 50MB limit"); } + return { + s: resp.getResponseCode(), + h: _respHeaders(resp), + b: respContentB64, + }; + } catch (encodeErr) { + throw new Error(`Base64 encode failed: ${ String(encodeErr) }`); } - return null; } /** - * Compute a compound cache key: - * MD5(URL | header1:value1 | header2:value2 | ...) - * - * Instead of reading the response Vary header (which would require - * fetching first — circular), we preemptively include the request - * headers that are known to cause response variation. This handles - * Vary: Accept-Encoding and Vary: Accept-Language without ever - * inspecting the response. - * - * Values are lowercased and whitespace-stripped so semantically - * identical requests from different clients produce the same hash. - * Missing and empty headers both map to "<none>" (same semantic). - */ -function _getCacheKey(url, reqHeaders) { - var parts = [url]; - - if (reqHeaders && typeof reqHeaders === "object") { - for (var i = 0; i < VARY_KEY_HEADERS.length; i++) { - var headerName = VARY_KEY_HEADERS[i]; - var rawValue = _getHeaderCaseInsensitive(reqHeaders, headerName); - - if (rawValue && String(rawValue).trim() !== "") { - parts.push(headerName + ":" + rawValue.toLowerCase().replace(/\s/g, "")); - } else { - parts.push(headerName + ":<none>"); - } - } - } else { - for (var j = 0; j < VARY_KEY_HEADERS.length; j++) { - parts.push(VARY_KEY_HEADERS[j] + ":<none>"); - } + * @param {ClientRequestSingle} req + * @return {GoogleAppsScript.Content.TextOutput} +*/ +function _doSingle (req) { + if (!req.u || typeof req.u !== "string" || !URL_PATTERN.test(req.u)) { + const errMsg = "invalid url: " + String(req.u); + Logger.log(`[ERROR] _doSingle: ${ errMsg }`); + return _relayResponse({ e: errMsg }); } - var compoundKey = parts.join("|"); - return _md5Hex(compoundKey); -} - -function _md5Hex(input) { - var rawHash = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, input); - return rawHash - .map(function (byte) { - var v = (byte < 0) ? 256 + byte : byte; - return ("0" + v.toString(16)).slice(-2); - }) - .join(""); -} - -// ═══════════════════════════════════════════════════════════ -// SPREADSHEET CACHE — CORE LOGIC -// ═══════════════════════════════════════════════════════════ - -/** - * Returns true if the request is eligible for the cache path: - * public GET, no body, no auth/cookie headers, cache configured. - */ -function _canUseCache(req) { - if ((req.m || "GET") !== "GET") return false; - if (req.b) return false; - if (!req.u || !req.u.match(/^https?:\/\//i)) return false; - if (CACHE_SPREADSHEET_ID === "CHANGE_ME_TO_CACHE_SPREADSHEET_ID") return false; - if (req.h && typeof req.h === "object") { - for (var k in req.h) { - if (req.h.hasOwnProperty(k) && CACHE_BUSTING_HEADERS[k.toLowerCase()]) { - return false; + // ── Normal relay ──────── + // Wrap the fetch + body encode in try/catch so any failure surfaces as + // a JSON error envelope the Rust client can parse. Without this, throws + // from UrlFetchApp.fetch (URL too long, payload too large, quota + // exhausted, 6-minute execution timeout) or from base64Encode (response + // body near Apps Script's ~50 MB ceiling can blow the V8 heap during + // encode) propagate unhandled, and Apps Script serves its default + // `<title>Web App` HTML error page — which the client then + // reports as "Relay failed: bad response: no json in: Web App>..." + // and the user has no signal as to the actual cause. Mirrors the + // per-item try/catch in _doBatch below. + try { + let opts = _buildOpts(req); + let resp = UrlFetchApp.fetch(req.u, opts); + + // Raw-return mode for exit-node path. + // r:true = return destination body verbatim so Rust gets {s,h,b} unwrapped. + if (req.r === true) { + try { + let respContent = resp.getContentText(); + /** @type {Object} */ + let respObj = JSON.parse(respContent); + if ([ "s", "h", "b" ].every(prop => respObj.hasOwnProperty(prop))) + return ContentService + .createTextOutput(respContent) + .setMimeType(ContentService.MimeType.JSON); + } catch { + // exit-node format not matched, continuing normal relay } } - } - - return true; -} - -/** - * Extract max-age (seconds) from a Cache-Control header value. - * Returns 0 if the directive forbids caching (no-cache / no-store / - * private). Falls back to CACHE_DEFAULT_TTL_SECONDS when no header - * is present. Clamped to [60, 2592000] (1 min – 30 days). - */ -function _parseMaxAge(cacheControlHeader) { - if (!cacheControlHeader) return CACHE_DEFAULT_TTL_SECONDS; - - var lower = cacheControlHeader.toLowerCase(); - - if ( - lower.indexOf("no-cache") !== -1 || - lower.indexOf("no-store") !== -1 || - lower.indexOf("private") !== -1 - ) { - return 0; - } - - var match = lower.match(/max-age=(\d+)/); - if (match) { - var ttl = parseInt(match[1], 10); - return Math.max(60, Math.min(ttl, 2592000)); - } - - return CACHE_DEFAULT_TTL_SECONDS; -} - -/** - * Rewrite time-sensitive headers so the client sees accurate - * Date, Age, and Cache-Control values reflecting cache age. - */ -function _refreshCachedHeaders(headersJson, timestamp) { - var headers = JSON.parse(headersJson); - var cachedAt = new Date(timestamp); - var now = new Date(); - var ageSeconds = Math.floor((now.getTime() - cachedAt.getTime()) / 1000); - if (ageSeconds < 0) ageSeconds = 0; - - headers["Date"] = now.toUTCString(); - headers["Age"] = String(ageSeconds); - - var originalCc = headers["Cache-Control"] || headers["cache-control"]; - if (originalCc) { - headers["X-Original-Cache-Control"] = originalCc; + return _relayResponse(_buildResponse(resp)); + } catch (err) { + const errMsg = `fetch failed: ${ String(err) }`; + Logger.log(`[ERROR] _doSingle(${ req.u }): ${ errMsg }`); + return _relayResponse({ e: errMsg }); } - - var remainingMaxAge = Math.max(0, _parseMaxAge(originalCc) - ageSeconds); - headers["Cache-Control"] = "public, max-age=" + remainingMaxAge; - - headers["X-Cache"] = "HIT from relay-spreadsheet"; - headers["X-Cached-At"] = cachedAt.toUTCString(); - - return JSON.stringify(headers); } /** - * Retrieve a cached response by compound cache key. - * Uses TextFinder for O(log n) lookup. Skips expired entries. - * Returns null on miss, expired entry, or unavailable sheet. + * Helper to process a batch of 50 requests + * @param { Array<GoogleAppsScript.URL_Fetch.URLFetchRequest>} fetchBatch + * @param { Array<number>} batchIndices + * @param { Map<number, RequestMapData>} requestMap + * @param { Map<number, Object> } errorMap */ -function _getFromCache(url, reqHeaders) { - var sheet = _initCacheSheet(); - if (!sheet) return null; - - var hash = _getCacheKey(url, reqHeaders); - var finder = sheet.createTextFinder(hash).matchEntireCell(true); - var found = finder.findNext(); - - if (found) { - // 8-column read. Legacy 7-column rows return undefined for the Z slot, - // which is falsy and falls through the not-gzipped branch below — fully - // compatible with caches written before the gzip-storage change. - var row = sheet.getRange(found.getRow(), 1, 1, 8).getValues()[0]; - - var expiresAt = row[6]; - if (expiresAt && expiresAt instanceof Date && expiresAt < new Date()) { - return null; +function _processBatch (fetchBatch, batchIndices, requestMap, errorMap) { + try { + const responses = UrlFetchApp.fetchAll(fetchBatch); + for (let j = 0; j < responses.length; j++) { + const originalIndex = batchIndices[ j ]; + requestMap.get(originalIndex).response = responses[ j ]; } + } catch (fetchAllErr) { + const fetchAllErrMsg = String(fetchAllErr); + Logger.log(`[WARN] fetchAll failed, retrying: ${ fetchAllErrMsg }`); + + // Fallback: retry safe methods individually + for (let j = 0; j < fetchBatch.length; j++) { + const originalIndex = batchIndices[ j ]; + const reqObj = requestMap.get(originalIndex); + + if (!SAFE_REPLAY_METHODS.has(reqObj.method)) { + errorMap.set(originalIndex, "batch fetchAll failed; unsafe method not replayed"); + continue; + } - var storedBody = row[4]; - var body; - if (row[7]) { - // Stored as base64(gzip(rawBytes)). The relay protocol's `b` field - // is base64(rawBytes), so decompress and re-encode for the wire. - var gzipped = Utilities.base64Decode(storedBody); - var raw = Utilities - .ungzip(Utilities.newBlob(gzipped, "application/x-gzip")) - .getBytes(); - body = Utilities.base64Encode(raw); - } else { - body = storedBody; + try { + const resp = UrlFetchApp.fetch(reqObj.request.url, reqObj.request); + reqObj.response = resp; + } catch (singleErr) { + const singleErrMsg = String(singleErr); + Logger.log(`[ERROR] _doBatch fallback[${ originalIndex }]: ${ singleErrMsg }`); + errorMap.set(originalIndex, singleErrMsg); + } } - - return { - status: row[2], - headers: _refreshCachedHeaders(row[3], row[5]), - body: body, - }; } - return null; } /** - * Fetch a URL and store the response in the spreadsheet cache - * using a circular buffer (O(1) writes). Skips storage on 5xx - * (transient outages must not poison a 24h slot), when Cache-Control - * forbids caching, or when the post-compression body exceeds - * CACHE_MAX_BODY_BYTES. Always returns the fetch result so the caller - * can serve the live response even when the cache write is skipped. - */ -function _fetchAndCache(url, reqHeaders) { - var sheet = _initCacheSheet(); - if (!sheet) return null; - - try { - var response = UrlFetchApp.fetch(url, { muteHttpExceptions: true }); - var status = response.getResponseCode(); - var headers = _respHeaders(response); - var bodyBytes = response.getContent(); - var rawB64 = Utilities.base64Encode(bodyBytes); - var headersJson = JSON.stringify(headers); - var liveResult = { status: status, headers: headersJson, body: rawB64 }; - - // 5xx never enters the cache. A flapping upstream returning 503 once - // would otherwise pin that response for 24h and break the URL for - // every subsequent client until expiry. - if (status >= 500) return liveResult; - - var cacheControl = - headers["Cache-Control"] || headers["cache-control"] || null; - var ttlSeconds = _parseMaxAge(cacheControl); - - if (ttlSeconds === 0) return liveResult; - - // Negative caching: cap TTL on persistent 4xx when upstream is silent. - // If they explicitly stated a max-age for the 404, we honor it instead - // — the origin knows best when it spoke up. - if (NEGATIVE_CACHE_STATUSES[status] && !cacheControl) { - ttlSeconds = NEGATIVE_CACHE_TTL_SECONDS; - } - - // Decide whether to gzip-store. Skip when upstream is already encoded - // (avoids double-compressing gzip/br/zstd payloads) and when the body - // is too small to overcome gzip's header overhead. - var contentEncoding = String( - headers["Content-Encoding"] || headers["content-encoding"] || "" - ).toLowerCase(); - var alreadyEncoded = contentEncoding && contentEncoding !== "identity"; - var storedBody; - var storedZ; - if (alreadyEncoded || bodyBytes.length < GZIP_MIN_BYTES) { - storedBody = rawB64; - storedZ = 0; - } else { - storedBody = Utilities.base64Encode( - Utilities.gzip(Utilities.newBlob(bodyBytes)).getBytes() - ); - storedZ = 1; + * @param {Array<ClientRequest>} items + * @returns {GoogleAppsScript.Content.TextOutput} +*/ +function _doBatch (items) { + /** @type {Map<number, {request: Object, method: string, response: GoogleAppsScript.URL_Fetch.HTTPResponse | null}>} */ + let requestMap = new Map(); + /** @type {Map<number, string>} */ + let errorMap = new Map(); + + // Build request map + for (let i = 0; i < items.length; i++) { + let item = items[ i ]; + if (!item || typeof item !== "object") { + errorMap.set(i, "bad item"); + continue; } - - // Cell-size safety gate, applied after compression so that a 100 KB - // text body that gzips to ~15 KB now fits where it previously bailed. - if (storedBody.length > CACHE_MAX_BODY_BYTES) return liveResult; - - var hash = _getCacheKey(url, reqHeaders); - var timestamp = new Date(); - var expiresAt = new Date(timestamp.getTime() + ttlSeconds * 1000); - - // Safety: fallback if Date math produces invalid result - if (isNaN(expiresAt.getTime())) { - expiresAt = new Date(timestamp.getTime() + CACHE_DEFAULT_TTL_SECONDS * 1000); + if (!item.u || typeof item.u !== "string" || !URL_PATTERN.test(item.u)) { + const errMsg = `bad url: ${ String(item.u) }`; + Logger.log(`[ERROR] _doBatch[${ i }]: ${ errMsg }`); + errorMap.set(i, errMsg); + continue; } - - var rowData = [ - hash, - url, - status, - headersJson, - storedBody, - timestamp.toISOString(), - expiresAt, - storedZ, - ]; - - // Circular buffer write (O(1)) - var metaSheet = _getMetaSheet(); - if (metaSheet) { - _ensureRowsAllocated(sheet); - var writeRow = _getNextCursor(sheet, metaSheet); - sheet.getRange(writeRow, 1, 1, 8).setValues([rowData]); - _advanceCursor(metaSheet, writeRow); - } else { - // Fallback: simple append if meta sheet is unavailable - sheet.appendRow(rowData); + try { + const method = (item.m?.toLowerCase?.() ?? "get"); + const request = { url: item.u, ..._buildOpts(item) }; + requestMap.set(i, { request, method, response: null }); + } catch (buildErr) { + const errMsg = String(buildErr); + Logger.log(`[ERROR] _doBatch[${ i }] _buildOpts: ${ errMsg }`); + errorMap.set(i, errMsg); } - - return liveResult; - } catch (e) { - return null; } -} - -// ═══════════════════════════════════════════════════════════ -// SPREADSHEET CACHE — DIAGNOSTICS -// ═══════════════════════════════════════════════════════════ -function getCacheStats() { - var sheet = _initCacheSheet(); - if (!sheet) { - console.log("Cache is not enabled or spreadsheet unavailable."); - return; + if (requestMap.size === 0) { + // All items failed validation + let results = []; + for (let i = 0; i < items.length; i++) { + results.push({ e: String(errorMap.get(i)) }); + } + return _relayResponse({ q: results }); } - var data = sheet.getDataRange().getValues(); - var totalEntries = data.length - 1; - var now = new Date(); - var expiredCount = 0; + // Single-item fast path + if (requestMap.size === 1) { + const [ originalIndex, data ] = requestMap.entries().next().value; + try { + const resp = UrlFetchApp.fetch(data.request.url, data.request); + data.response = resp; + } catch (singleErr) { + const singleErrMsg = String(singleErr); + Logger.log(`[ERROR] _doBatch (fetching single item): ${ singleErrMsg }`); + errorMap.set(originalIndex, singleErrMsg); + } - for (var i = 1; i < data.length; i++) { - var expiresAt = data[i][6]; - if (expiresAt && expiresAt instanceof Date && expiresAt < now) { - expiredCount++; + } else { + // Batch mode + let requestCount = 0; + let fetchBatch = []; + let batchIndices = []; + + for (const [ originalIndex, data ] of requestMap) { + fetchBatch.push(data.request); + batchIndices.push(originalIndex); + requestCount++; + + // Process batch when it reaches 50 or we're at the end + if (fetchBatch.length === 50 || requestCount === requestMap.size) { + _processBatch(fetchBatch, batchIndices, requestMap, errorMap); + fetchBatch = []; + batchIndices = []; + } } } - var metaSheet = _getMetaSheet(); - var cursorInfo = "N/A"; - if (metaSheet) { - cursorInfo = String(metaSheet.getRange(CACHE_META_CURSOR_CELL).getValue()); + // Build results + /** @type {Array<RelaySingleResponse | RelayErrorResponse>} */ + let results = []; + for (let i = 0; i < items.length; i++) { + if (errorMap.has(i)) { + results.push({ e: String(errorMap.get(i)) }); + } else if (requestMap.has(i) && requestMap.get(i).response) { + try { + results.push(_buildResponse(requestMap.get(i).response)); + } catch (err) { + results.push({ e: `fetch failed: ${ String(err) }` }); + } + } else { + results.push({ e: "fetch failed" }); + } } - console.log("=== CACHE STATS ==="); - console.log("Total rows used: " + totalEntries + " / " + CACHE_MAX_ROWS); - console.log("Active entries: " + (totalEntries - expiredCount)); - console.log("Expired entries: " + expiredCount); - console.log("Cursor position: " + cursorInfo); - console.log("Max body size: " + CACHE_MAX_BODY_BYTES + " chars"); - console.log("Default TTL: " + CACHE_DEFAULT_TTL_SECONDS + " sec"); - console.log("Vary key headers: " + VARY_KEY_HEADERS.join(", ")); - if (totalEntries > 0) { - console.log("Oldest entry: " + data[1][5]); - console.log("Newest entry: " + data[data.length - 1][5]); - } + return _relayResponse({ q: results }); } -function clearExpiredCache() { - var sheet = _initCacheSheet(); - if (!sheet) { - console.log("Cache is not enabled."); - return; +/** @param {GoogleAppsScript.Events.DoPost} e */ +function doPost (e) { + if (!AUTH_KEY) { + Logger.log("[ERROR] doPost: AUTH_KEY not configured"); + return _decoyOrError({ e: "AUTH_KEY not configured" }); } - - var data = sheet.getDataRange().getValues(); - var now = new Date(); - var rowsToClear = []; - - for (var i = 1; i < data.length; i++) { - var expiresAt = data[i][6]; - if (expiresAt && expiresAt instanceof Date && expiresAt < now) { - rowsToClear.push(i + 1); + try { + /** @type {ClientRequestSingle | ClientRequestBatch} **/ + let req = JSON.parse(e.postData.contents); + if (req.k !== AUTH_KEY) { + Logger.log("[WARN] doPost: unauthorized attempt"); + return _decoyOrError({ e: "unauthorized" }); } - } - - for (var j = 0; j < rowsToClear.length; j++) { - sheet.getRange(rowsToClear[j], 1, 1, 8).clearContent(); - } - - console.log("Cleared " + rowsToClear.length + " expired entries (" + - (data.length - 1 - rowsToClear.length) + " remaining)."); -} -function clearEntireCache() { - var sheet = _initCacheSheet(); - if (sheet) { - var totalRows = sheet.getDataRange().getNumRows(); - if (totalRows > 1) { - sheet.getRange(2, 1, totalRows - 1, 8).clearContent(); + // Batch mode: { k, q: [...] } + if ("q" in req) { + if (req.q.length === 0) return _relayResponse({ q: [] }); + return _doBatch(req.q); } - } + // Single mode: { k, m, u, h, b, ct, r } + return _doSingle(req); + } catch (err) { + // Parse failures of the request body are also probe-shaped — a real + // mhrv-rs client never sends invalid JSON. Decoy for the same reason. - var metaSheet = _getMetaSheet(); - if (metaSheet) { - metaSheet.getRange(CACHE_META_CURSOR_CELL).setValue(2); + const errMsg = String(err); + Logger.log(`[ERROR] doPost parse: ${ errMsg }`); + return _decoyOrError({ e: errMsg }); } - - console.log("Cache wiped. Cursor reset to row 2."); }