Files
rustpad/static/js/api.js
T
2026-08-01 00:15:37 +02:00

294 lines
11 KiB
JavaScript

/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { logDebug, logError, logWarn } from "@rustpad/logger";
const DEFAULT_ERRORS = {
400: "Invalid request.",
401: "Authentication required.",
403: "Access denied.",
404: "The requested resource was not found.",
405: "This operation is not allowed.",
408: "The request timed out. Try again.",
409: "The requested change conflicts with existing data.",
413: "The selected file exceeds the allowed upload limit.",
425: "The request was sent too early. Try again.",
429: "Too many requests. Try again later.",
500: "Server error. Try again later.",
502: "The server returned an invalid response. Try again.",
503: "Service temporarily unavailable.",
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`;
return `${bytes} B`;
}
function clearExpiredSession() {
localStorage.removeItem("rustpad:auth-state");
localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token");
localStorage.removeItem("rustpad:nickname");
sessionStorage.removeItem("rustpad:nickname");
document.cookie = "rustpad_nickname=; Path=/; SameSite=Lax; Max-Age=0";
window.dispatchEvent(new CustomEvent("rustpad:session-expired"));
}
async function clearSessionIfInvalid() {
if (!localStorage.getItem("rustpad:auth-state")) return;
try {
const response = await fetch("/api/auth/me", {
signal: AbortSignal.timeout(5000),
});
if (response.status === 401) clearExpiredSession();
} catch {
// A network failure does not prove that the session is invalid.
}
}
function validateUploadSize(body) {
if (!(body instanceof FormData)) return;
const maxBytes = Number(window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes || 0);
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
for (const value of body.values()) {
if (value instanceof File && value.size > maxBytes) {
const error = new Error(`The selected file is ${formatBytes(value.size)}. The upload limit is ${formatBytes(maxBytes)}.`);
error.status = 413;
throw error;
}
}
}
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;
}
function requestError(status, data = {}) {
const error = new Error(data.error || DEFAULT_ERRORS[status] || `Request failed (${status}).`);
error.status = status;
return error;
}
function formDataFileSize(body) {
if (!(body instanceof FormData)) return 0;
let size = 0;
for (const value of body.values()) if (value instanceof File) size += value.size;
return size;
}
export async function api(path, options = {}) {
validateUploadSize(options.body);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
let headers = await requestHeaders(options, options.body);
const started = performance.now();
logDebug("api.request", { method: options.method || "GET", path });
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") || "";
const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {};
if (!response.ok) {
if (response.status === 401) await clearSessionIfInvalid();
const error = requestError(response.status, data);
logWarn("api.failed", { method: options.method || "GET", path, status: response.status, message: error.message });
throw error;
}
return data;
} catch (error) {
if (error.name === "AbortError") {
logWarn("api.timeout", { method: options.method || "GET", path });
throw new Error("Timed out");
}
logError("api.network_error", error, { method: options.method || "GET", path });
if (options.body instanceof FormData && error instanceof TypeError) {
throw new Error("Upload failed before the server returned a response. The file may exceed the server or proxy upload limit.");
}
throw error;
} finally {
clearTimeout(timeout);
}
}
export function uploadWithProgress(path, options = {}) {
validateUploadSize(options.body);
const method = options.method || "POST";
const fallbackTotal = formDataFileSize(options.body);
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
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) {
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);
});
})();
}