Preserve aws4fetch Request headers in the shell fetch interceptor, and proxy cross-origin HTTP through a native reqwest command so Windows/Android builds work even when remote CORS only allows web origins.
370 lines
11 KiB
JavaScript
370 lines
11 KiB
JavaScript
(function () {
|
|
if (window.__web2appShellInstalled) return;
|
|
window.__web2appShellInstalled = true;
|
|
|
|
function isTauriShell() {
|
|
var protocol = location.protocol;
|
|
var host = location.hostname;
|
|
return (
|
|
protocol === "tauri:" ||
|
|
protocol === "file:" ||
|
|
host === "asset.localhost" ||
|
|
host === "tauri.localhost"
|
|
);
|
|
}
|
|
|
|
if (!isTauriShell()) return;
|
|
|
|
var TOKEN_KEYS = ["sprout2fa_session", "__web2app_bearer_token"];
|
|
/**
|
|
* Bodies larger than this stay on the browser fetch path (needs server CORS
|
|
* for https://tauri.localhost). Keep high enough for typical R2 uploads.
|
|
*/
|
|
var PROXY_BODY_LIMIT = 64 * 1024 * 1024;
|
|
/** Soft warn threshold for huge proxied responses (base64 over IPC). */
|
|
var PROXY_RESPONSE_LIMIT = 64 * 1024 * 1024;
|
|
|
|
function readToken() {
|
|
try {
|
|
for (var i = 0; i < TOKEN_KEYS.length; i++) {
|
|
var value = localStorage.getItem(TOKEN_KEYS[i]);
|
|
if (value) return value;
|
|
}
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function writeToken(token) {
|
|
if (!token) return;
|
|
try {
|
|
for (var i = 0; i < TOKEN_KEYS.length; i++) {
|
|
localStorage.setItem(TOKEN_KEYS[i], token);
|
|
}
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function clearToken() {
|
|
try {
|
|
for (var i = 0; i < TOKEN_KEYS.length; i++) {
|
|
localStorage.removeItem(TOKEN_KEYS[i]);
|
|
}
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
if ("serviceWorker" in navigator) {
|
|
navigator.serviceWorker.getRegistrations().then(function (regs) {
|
|
for (var i = 0; i < regs.length; i++) regs[i].unregister();
|
|
});
|
|
navigator.serviceWorker.register = function () {
|
|
return Promise.resolve({
|
|
active: null,
|
|
installing: null,
|
|
waiting: null,
|
|
unregister: function () {
|
|
return Promise.resolve(true);
|
|
},
|
|
update: function () {
|
|
return Promise.resolve();
|
|
},
|
|
});
|
|
};
|
|
}
|
|
|
|
function getInvoke() {
|
|
try {
|
|
var core = window.__TAURI__ && window.__TAURI__.core;
|
|
if (core && typeof core.invoke === "function") {
|
|
return core.invoke.bind(core);
|
|
}
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveUrl(input) {
|
|
if (typeof input === "string") return input;
|
|
if (input instanceof URL) return input.href;
|
|
if (input && typeof input.url === "string") return input.url;
|
|
return "";
|
|
}
|
|
|
|
function resolveMethod(input, init) {
|
|
if (init && init.method) return String(init.method).toUpperCase();
|
|
if (input instanceof Request) return input.method.toUpperCase();
|
|
return "GET";
|
|
}
|
|
|
|
/**
|
|
* Collect headers without inventing an empty Headers that would later
|
|
* overwrite a signed Request (aws4fetch passes fetch(Request) only).
|
|
*/
|
|
function collectHeaders(input, init) {
|
|
var headers = new Headers();
|
|
if (input instanceof Request) {
|
|
input.headers.forEach(function (value, key) {
|
|
headers.set(key, value);
|
|
});
|
|
}
|
|
if (init && init.headers) {
|
|
new Headers(init.headers).forEach(function (value, key) {
|
|
headers.set(key, value);
|
|
});
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function headersToObject(headers) {
|
|
var out = {};
|
|
headers.forEach(function (value, key) {
|
|
// Host is set by the real client; hop-by-hop headers are stripped natively.
|
|
if (key.toLowerCase() === "host") return;
|
|
out[key] = value;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function arrayBufferToBase64(buffer) {
|
|
var bytes = new Uint8Array(buffer);
|
|
var binary = "";
|
|
var chunk = 0x8000;
|
|
for (var i = 0; i < bytes.length; i += chunk) {
|
|
binary += String.fromCharCode.apply(
|
|
null,
|
|
bytes.subarray(i, i + chunk),
|
|
);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function base64ToUint8Array(b64) {
|
|
var binary = atob(b64 || "");
|
|
var len = binary.length;
|
|
var bytes = new Uint8Array(len);
|
|
for (var i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
function bodyToArrayBuffer(input, init) {
|
|
if (init && init.body != null) {
|
|
var body = init.body;
|
|
if (body === "") return Promise.resolve(null);
|
|
if (typeof body === "string") {
|
|
return Promise.resolve(new TextEncoder().encode(body).buffer);
|
|
}
|
|
if (body instanceof ArrayBuffer) return Promise.resolve(body);
|
|
if (ArrayBuffer.isView(body)) {
|
|
return Promise.resolve(
|
|
body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
|
|
);
|
|
}
|
|
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
return body.arrayBuffer();
|
|
}
|
|
if (
|
|
typeof URLSearchParams !== "undefined" &&
|
|
body instanceof URLSearchParams
|
|
) {
|
|
return Promise.resolve(new TextEncoder().encode(body.toString()).buffer);
|
|
}
|
|
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
// FormData needs multipart framing; fall back to browser fetch.
|
|
return Promise.reject(new Error("FORMDATA_UNSUPPORTED"));
|
|
}
|
|
if (
|
|
typeof ReadableStream !== "undefined" &&
|
|
body instanceof ReadableStream
|
|
) {
|
|
return new Response(body).arrayBuffer();
|
|
}
|
|
return Promise.resolve(null);
|
|
}
|
|
if (input instanceof Request) {
|
|
// Clone so we do not lock the original Request body stream.
|
|
return input
|
|
.clone()
|
|
.arrayBuffer()
|
|
.then(function (buf) {
|
|
return buf && buf.byteLength > 0 ? buf : null;
|
|
})
|
|
.catch(function () {
|
|
return null;
|
|
});
|
|
}
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
function maybeInjectToken(headers) {
|
|
if (headers.has("Authorization")) return;
|
|
var token = readToken();
|
|
if (token) headers.set("Authorization", "Bearer " + token);
|
|
}
|
|
|
|
function trackAuthResponse(requestUrl, response) {
|
|
if (!response || !response.ok) return response;
|
|
var path = requestUrl.pathname.replace(/\/$/, "");
|
|
if (/\/auth\/login$/i.test(path)) {
|
|
response
|
|
.clone()
|
|
.json()
|
|
.then(function (data) {
|
|
if (data && typeof data.token === "string" && data.token) {
|
|
writeToken(data.token);
|
|
}
|
|
})
|
|
.catch(function () {
|
|
/* ignore */
|
|
});
|
|
}
|
|
if (/\/auth\/logout$/i.test(path)) {
|
|
clearToken();
|
|
}
|
|
return response;
|
|
}
|
|
|
|
function callOriginalFetch(input, init) {
|
|
if (init === undefined) return originalFetch.call(window, input);
|
|
return originalFetch.call(window, input, init);
|
|
}
|
|
|
|
/**
|
|
* Proxy cross-origin HTTP(S) through Rust so WebView CORS never applies.
|
|
* Origin of packaged apps is https://tauri.localhost (etc.), which remote
|
|
* APIs (R2/S3) usually do not list — even when AllowedOrigins is ["*"],
|
|
* signed preflights and some WebView builds still fail.
|
|
*/
|
|
function proxyFetch(input, init) {
|
|
var invoke = getInvoke();
|
|
if (!invoke) return null;
|
|
|
|
var url = resolveUrl(input);
|
|
var method = resolveMethod(input, init);
|
|
var headers = collectHeaders(input, init);
|
|
maybeInjectToken(headers);
|
|
|
|
return bodyToArrayBuffer(input, init)
|
|
.then(function (buf) {
|
|
if (buf && buf.byteLength > PROXY_BODY_LIMIT) {
|
|
// Large upload: browser path (needs R2 CORS for tauri.localhost).
|
|
return callOriginalFetch(url, {
|
|
method: method,
|
|
headers: headers,
|
|
body: buf,
|
|
});
|
|
}
|
|
|
|
var bodyBase64 =
|
|
buf && buf.byteLength > 0 ? arrayBufferToBase64(buf) : null;
|
|
|
|
return invoke("proxy_fetch", {
|
|
method: method,
|
|
url: url,
|
|
headers: headersToObject(headers),
|
|
bodyBase64: bodyBase64,
|
|
}).then(function (result) {
|
|
if (!result || typeof result.status !== "number") {
|
|
throw new TypeError("Failed to fetch");
|
|
}
|
|
if (
|
|
result.bodyBase64 &&
|
|
result.bodyBase64.length * 0.75 > PROXY_RESPONSE_LIMIT
|
|
) {
|
|
console.warn(
|
|
"[web2app] proxied response is very large; consider presigned URLs",
|
|
);
|
|
}
|
|
var bytes = base64ToUint8Array(result.bodyBase64 || "");
|
|
var responseHeaders = new Headers();
|
|
var pairs = result.headers || [];
|
|
for (var i = 0; i < pairs.length; i++) {
|
|
var pair = pairs[i];
|
|
if (pair && pair.length >= 2) {
|
|
try {
|
|
responseHeaders.append(pair[0], pair[1]);
|
|
} catch (_) {
|
|
/* invalid header name/value */
|
|
}
|
|
}
|
|
}
|
|
return new Response(bytes, {
|
|
status: result.status,
|
|
statusText: result.statusText || "",
|
|
headers: responseHeaders,
|
|
});
|
|
});
|
|
})
|
|
.catch(function (err) {
|
|
if (err && err.message === "FORMDATA_UNSUPPORTED") {
|
|
return callOriginalFetch(url, {
|
|
method: method,
|
|
headers: headers,
|
|
body: init && init.body,
|
|
});
|
|
}
|
|
throw err;
|
|
});
|
|
}
|
|
|
|
var originalFetch = window.fetch;
|
|
window.fetch = function (input, init) {
|
|
var url = resolveUrl(input);
|
|
var requestUrl;
|
|
try {
|
|
requestUrl = new URL(url, location.href);
|
|
} catch (_) {
|
|
return callOriginalFetch(input, init);
|
|
}
|
|
|
|
var crossOrigin = requestUrl.origin !== location.origin;
|
|
var isHttp =
|
|
requestUrl.protocol === "http:" || requestUrl.protocol === "https:";
|
|
|
|
// Same-origin / non-HTTP: never rewrite (preserves Request signatures).
|
|
if (!crossOrigin || !isHttp) {
|
|
return callOriginalFetch(input, init);
|
|
}
|
|
|
|
var proxied = proxyFetch(input, init);
|
|
if (proxied) {
|
|
return proxied.then(function (response) {
|
|
return trackAuthResponse(requestUrl, response);
|
|
});
|
|
}
|
|
|
|
// Tauri IPC not ready yet: pass through without clobbering Request headers.
|
|
// Only add a bearer token when Authorization is absent.
|
|
var headers = collectHeaders(input, init);
|
|
var hadAuth = headers.has("Authorization");
|
|
maybeInjectToken(headers);
|
|
if (!hadAuth && headers.has("Authorization")) {
|
|
var passInit = Object.assign({}, init || {}, {
|
|
method: resolveMethod(input, init),
|
|
headers: headers,
|
|
});
|
|
if (passInit.body == null && input instanceof Request) {
|
|
// Keep original Request if we only needed headers from it and body unset.
|
|
return callOriginalFetch(input.url, Object.assign({}, passInit, {
|
|
body: undefined,
|
|
})).then(function (response) {
|
|
return trackAuthResponse(requestUrl, response);
|
|
});
|
|
}
|
|
return callOriginalFetch(requestUrl.href, passInit).then(function (
|
|
response,
|
|
) {
|
|
return trackAuthResponse(requestUrl, response);
|
|
});
|
|
}
|
|
|
|
return callOriginalFetch(input, init).then(function (response) {
|
|
return trackAuthResponse(requestUrl, response);
|
|
});
|
|
};
|
|
})();
|