tokens and more
This commit is contained in:
+161
-116
@@ -26,6 +26,37 @@ const DEFAULT_ERRORS = {
|
||||
504: "The server took too long to respond. Try again.",
|
||||
};
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
const CSRF_REFRESH_MS = 20 * 60 * 1000;
|
||||
let csrfTokenPromise = null;
|
||||
let csrfTokenFetchedAt = 0;
|
||||
|
||||
async function csrfToken({ refresh = false } = {}) {
|
||||
if (refresh || Date.now() - csrfTokenFetchedAt >= CSRF_REFRESH_MS) {
|
||||
csrfTokenPromise = null;
|
||||
csrfTokenFetchedAt = 0;
|
||||
}
|
||||
if (!csrfTokenPromise) {
|
||||
csrfTokenPromise = fetch("/api/security/csrf", {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}).then(async response => {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || typeof data.token !== "string" || !data.token) {
|
||||
throw requestError(response.status, data);
|
||||
}
|
||||
csrfTokenFetchedAt = Date.now();
|
||||
return data.token;
|
||||
}).catch(error => {
|
||||
csrfTokenPromise = null;
|
||||
csrfTokenFetchedAt = 0;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return csrfTokenPromise;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
|
||||
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
|
||||
@@ -67,10 +98,14 @@ function validateUploadSize(body) {
|
||||
}
|
||||
}
|
||||
|
||||
function requestHeaders(options, body) {
|
||||
async function requestHeaders(options, body) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.delete("x-rustpad-user-token");
|
||||
if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
const method = String(options.method || "GET").toUpperCase();
|
||||
if (!SAFE_METHODS.has(method) && !headers.has("x-rustpad-csrf")) {
|
||||
headers.set("x-rustpad-csrf", await csrfToken());
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -92,10 +127,18 @@ export async function api(path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
const headers = requestHeaders(options, options.body);
|
||||
let headers = await requestHeaders(options, options.body);
|
||||
const started = performance.now();
|
||||
logDebug("api.request", { method: options.method || "GET", path });
|
||||
const response = await fetch(path, { ...options, headers, signal: controller.signal });
|
||||
let response = await fetch(path, { ...options, headers, credentials: "same-origin", signal: controller.signal });
|
||||
if (response.status === 403 && !SAFE_METHODS.has(String(options.method || "GET").toUpperCase())) {
|
||||
const preview = await response.clone().json().catch(() => ({}));
|
||||
if (/security token/i.test(preview.error || "")) {
|
||||
headers = new Headers(headers);
|
||||
headers.set("x-rustpad-csrf", await csrfToken({ refresh: true }));
|
||||
response = await fetch(path, { ...options, headers, credentials: "same-origin", signal: controller.signal });
|
||||
}
|
||||
}
|
||||
const durationMs = Math.round(performance.now() - started);
|
||||
logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs });
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
@@ -129,120 +172,122 @@ export function uploadWithProgress(path, options = {}) {
|
||||
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
|
||||
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const headers = requestHeaders(options, options.body);
|
||||
const started = performance.now();
|
||||
let lastAt = started;
|
||||
let lastLoaded = 0;
|
||||
let speed = 0;
|
||||
let stallTimer = null;
|
||||
let responseTimer = null;
|
||||
let stalled = false;
|
||||
let responseTimedOut = false;
|
||||
let externallyAborted = false;
|
||||
return (async () => {
|
||||
const headers = await requestHeaders({ ...options, method }, options.body);
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const started = performance.now();
|
||||
let lastAt = started;
|
||||
let lastLoaded = 0;
|
||||
let speed = 0;
|
||||
let stallTimer = null;
|
||||
let responseTimer = null;
|
||||
let stalled = false;
|
||||
let responseTimedOut = false;
|
||||
let externallyAborted = false;
|
||||
|
||||
const clearStallTimer = () => {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
};
|
||||
const armStallTimer = () => {
|
||||
clearStallTimer();
|
||||
stallTimer = setTimeout(() => {
|
||||
stalled = true;
|
||||
xhr.abort();
|
||||
}, stallTimeoutMs);
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearStallTimer();
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = null;
|
||||
options.signal?.removeEventListener("abort", abortFromSignal);
|
||||
};
|
||||
const abortFromSignal = () => {
|
||||
externallyAborted = true;
|
||||
xhr.abort();
|
||||
};
|
||||
const fail = error => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
xhr.open(method, path, true);
|
||||
xhr.responseType = "text";
|
||||
for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value);
|
||||
|
||||
xhr.upload.addEventListener("loadstart", () => {
|
||||
armStallTimer();
|
||||
options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 });
|
||||
});
|
||||
xhr.upload.addEventListener("progress", event => {
|
||||
const now = performance.now();
|
||||
const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001);
|
||||
const deltaBytes = Math.max(0, event.loaded - lastLoaded);
|
||||
const instantaneousSpeed = deltaBytes / elapsedSeconds;
|
||||
speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed;
|
||||
lastAt = now;
|
||||
lastLoaded = event.loaded;
|
||||
const total = event.lengthComputable ? event.total : fallbackTotal;
|
||||
const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null;
|
||||
options.onProgress?.({ loaded: event.loaded, total, speed, percent });
|
||||
if (total > 0 && event.loaded >= total) clearStallTimer();
|
||||
else armStallTimer();
|
||||
});
|
||||
xhr.upload.addEventListener("load", event => {
|
||||
clearStallTimer();
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = setTimeout(() => {
|
||||
responseTimedOut = true;
|
||||
xhr.abort();
|
||||
}, responseTimeoutMs);
|
||||
const total = event.lengthComputable ? event.total : fallbackTotal;
|
||||
options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" });
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
cleanup();
|
||||
const durationMs = Math.round(performance.now() - started);
|
||||
logDebug("api.response", { method, path, status: xhr.status, durationMs });
|
||||
let data = {};
|
||||
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { }
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(data);
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401) void clearSessionIfInvalid();
|
||||
const error = requestError(xhr.status, data);
|
||||
logWarn("api.failed", { method, path, status: xhr.status, message: error.message });
|
||||
reject(error);
|
||||
});
|
||||
xhr.addEventListener("error", () => {
|
||||
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
|
||||
logError("api.network_error", error, { method, path });
|
||||
fail(error);
|
||||
});
|
||||
xhr.addEventListener("abort", () => {
|
||||
const error = new Error(stalled
|
||||
? "Upload stopped making progress. Check the connection and try again."
|
||||
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
|
||||
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
|
||||
error.name = externallyAborted ? "AbortError" : "UploadError";
|
||||
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
|
||||
fail(error);
|
||||
});
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
const clearStallTimer = () => {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
};
|
||||
const armStallTimer = () => {
|
||||
clearStallTimer();
|
||||
stallTimer = setTimeout(() => {
|
||||
stalled = true;
|
||||
xhr.abort();
|
||||
}, stallTimeoutMs);
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearStallTimer();
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = null;
|
||||
options.signal?.removeEventListener("abort", abortFromSignal);
|
||||
};
|
||||
const abortFromSignal = () => {
|
||||
externallyAborted = true;
|
||||
const error = new Error("Upload cancelled.");
|
||||
error.name = "AbortError";
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener("abort", abortFromSignal, { once: true });
|
||||
}
|
||||
xhr.abort();
|
||||
};
|
||||
const fail = error => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
logDebug("api.request", { method, path });
|
||||
xhr.send(options.body ?? null);
|
||||
});
|
||||
xhr.open(method, path, true);
|
||||
xhr.responseType = "text";
|
||||
for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value);
|
||||
|
||||
xhr.upload.addEventListener("loadstart", () => {
|
||||
armStallTimer();
|
||||
options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 });
|
||||
});
|
||||
xhr.upload.addEventListener("progress", event => {
|
||||
const now = performance.now();
|
||||
const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001);
|
||||
const deltaBytes = Math.max(0, event.loaded - lastLoaded);
|
||||
const instantaneousSpeed = deltaBytes / elapsedSeconds;
|
||||
speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed;
|
||||
lastAt = now;
|
||||
lastLoaded = event.loaded;
|
||||
const total = event.lengthComputable ? event.total : fallbackTotal;
|
||||
const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null;
|
||||
options.onProgress?.({ loaded: event.loaded, total, speed, percent });
|
||||
if (total > 0 && event.loaded >= total) clearStallTimer();
|
||||
else armStallTimer();
|
||||
});
|
||||
xhr.upload.addEventListener("load", event => {
|
||||
clearStallTimer();
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = setTimeout(() => {
|
||||
responseTimedOut = true;
|
||||
xhr.abort();
|
||||
}, responseTimeoutMs);
|
||||
const total = event.lengthComputable ? event.total : fallbackTotal;
|
||||
options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" });
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
cleanup();
|
||||
const durationMs = Math.round(performance.now() - started);
|
||||
logDebug("api.response", { method, path, status: xhr.status, durationMs });
|
||||
let data = {};
|
||||
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { }
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(data);
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401) void clearSessionIfInvalid();
|
||||
const error = requestError(xhr.status, data);
|
||||
logWarn("api.failed", { method, path, status: xhr.status, message: error.message });
|
||||
reject(error);
|
||||
});
|
||||
xhr.addEventListener("error", () => {
|
||||
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
|
||||
logError("api.network_error", error, { method, path });
|
||||
fail(error);
|
||||
});
|
||||
xhr.addEventListener("abort", () => {
|
||||
const error = new Error(stalled
|
||||
? "Upload stopped making progress. Check the connection and try again."
|
||||
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
|
||||
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
|
||||
error.name = externallyAborted ? "AbortError" : "UploadError";
|
||||
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
|
||||
fail(error);
|
||||
});
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
externallyAborted = true;
|
||||
const error = new Error("Upload cancelled.");
|
||||
error.name = "AbortError";
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener("abort", abortFromSignal, { once: true });
|
||||
}
|
||||
|
||||
logDebug("api.request", { method, path });
|
||||
xhr.send(options.body ?? null);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user