Files
FOSSBilling/frontend/core/api.ts

550 lines
18 KiB
TypeScript

// @ts-nocheck -- Runtime DOM/widget integration; converted to TS without changing behavior.
/**
* FOSSBilling API for JavaScript.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license Apache-2.0
*
* This source file is subject to the Apache-2.0 License that is bundled
* with this source code in the file LICENSE
*/
import { parseDataAttr } from './parse-data-attr.mts';
import { completeApiAction } from './api-completion.ts';
import {
injectCSRFToken,
buildRequestBody,
buildHeaders,
parseResponseBody,
validateHttpResponse,
interpretResponse,
normalizeApiError,
} from './api-helpers.mts';
import {
dispatchLinkAction,
createLinkLoadingState,
} from './link-helpers.mts';
/**
* Tools for the API wrapper.
*/
const FOSSBilling = window.FOSSBilling = window.FOSSBilling || {};
const Tools = {
/**
* Constructs the full URL for an API endpoint.
* If the provided URL is relative, it's resolved against the application's base API URL.
*
* @param {string} url The API endpoint (e.g., "guest/system/company") or a full URL.
* @returns {string} The complete URL for the API call.
*/
getBaseURL: function (url) {
if (typeof url !== 'string' || !url.trim()) {
return `${window.location.origin}/api/`;
}
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (url.includes('index.php?_url=/api/') || url.includes('?_url=/api/')) {
return new URL(url, `${window.location.origin}/`).toString();
}
const base = `${window.location.origin}/api/`;
let normalized = url;
if (normalized.startsWith('/')) {
normalized = normalized.slice(1);
}
if (normalized.startsWith('api/')) {
normalized = normalized.slice(4);
}
return new URL(normalized, base).toString();
},
/**
* @returns {string|null} The CSRF token from cookie, or null if not found.
*/
getCSRFToken: function () {
const match = document.cookie.match(/(?:^|;\s*)fossbilling_csrf=([^;]*)/)
|| document.cookie.match(/(?:^|;\s*)csrf_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
/**
* Check if a string is valid JSON or not.
*
* @param {string} jsonString The string to check.
* @returns {boolean} True if the string is valid JSON, or false if it is not.
*/
isJSON: function (jsonString) {
if (typeof jsonString !== 'string') {
return false;
}
try {
JSON.parse(jsonString);
return true;
} catch (error) {
return false;
}
},
/**
* Converts a FormData object into a urlencoded string.
*
* @param {FormData} formData The FormData object to serialize.
* @returns {string} Serialized string of the FormData.
*/
serializeFormData: function (formData) {
const params = new URLSearchParams(formData);
if (!formData.has('CSRFToken')) {
const token = Tools.getCSRFToken();
if (token) {
params.append('CSRFToken', token);
}
}
return params.toString();
},
/**
* Converts a FormData object into a valid object.
*
* @param {FormData} formData The FormData object to serialize.
* @returns {object} The reformatted object.
*/
serializeFormDataToObject: function (formData) {
const obj = {};
for (const [key, value] of formData.entries()) {
if (key.endsWith('[]')) {
const plainKey = key.slice(0, -2);
if (!obj[plainKey]) {
obj[plainKey] = [];
}
obj[plainKey].push(value);
} else if (Object.prototype.hasOwnProperty.call(obj, key)) {
obj[key] = value;
} else {
obj[key] = value;
}
}
if (!Object.prototype.hasOwnProperty.call(obj, 'CSRFToken')) {
const token = Tools.getCSRFToken();
if (token) {
obj.CSRFToken = token;
}
}
const reformattedObj = {};
Object.keys(obj).forEach((originalKey) => {
const parts = originalKey.match(/[^[\]]+/g) || [originalKey];
let currentContext = reformattedObj;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (i === parts.length - 1) {
currentContext[part] = obj[originalKey];
} else {
if (!Object.prototype.hasOwnProperty.call(currentContext, part) || typeof currentContext[part] !== 'object' || currentContext[part] === null) {
currentContext[part] = {};
}
currentContext = currentContext[part];
}
}
});
return reformattedObj;
},
/**
* Converts a FormData object into a valid JSON string.
*
* @param {FormData} formData The FormData object to serialize.
* @returns {string} JSON string of the FormData object.
*/
serializeFormDataToJSON: function (formData) {
return JSON.stringify(this.serializeFormDataToObject(formData));
}
};
Tools.parseDataAttr = parseDataAttr;
/**
* Creates an API for a specific role (admin, client, guest).
*
* @param {string} role The role for the API (admin, client, guest).
* @returns {object} The API object for the specified role.
**/
function _createApiRole (role) {
const baseNamespaceUrlString = Tools.getBaseURL(role);
const createMethod = (method) => {
return function(endpoint, params, successHandler, errorHandler, enableLoader = true) {
if (typeof endpoint !== 'string' || !endpoint.trim()) {
throw new Error('Invalid endpoint: must be a non-empty string');
}
const requestUrl = new URL(endpoint, `${baseNamespaceUrlString}/`).toString();
API.makeRequest(method, requestUrl, params, successHandler, errorHandler, enableLoader);
};
};
return {
baseURL: baseNamespaceUrlString,
get: createMethod('GET'),
post: createMethod('POST'),
put: createMethod('PUT'),
delete: createMethod('DELETE'),
patch: createMethod('PATCH')
};
}
/**
* FOSSBilling API wrapper for JavaScript.
* @documentation https://docs.fossbilling.org/extensions-and-development/javascript/
*/
const API = {
/**
* Wrapper for the admin API.
* @documentation https://docs.fossbilling.org/extensions-and-development/javascript/
*/
admin: _createApiRole('admin'),
/**
* Wrapper for the client API.
* @documentation https://docs.fossbilling.org/extensions-and-development/javascript/
*/
client: _createApiRole('client'),
/**
* Wrapper for the guest API.
* @documentation https://docs.fossbilling.org/extensions-and-development/javascript/
*/
guest: _createApiRole('guest'),
/**
* Make a request to the API.
*
* @param {string} method The HTTP method to use.
* @param {string} url The URL to call.
* @param {object|string} [params] The parameters to send.
* @param {function} [successHandler] The function to call if the request is successful.
* @param {function} [errorHandler] The function to call if the request is unsuccessful.
* @param {boolean} [enableLoader=true] Enable or disable the usage of a loader. Custom themes simply need to provide one with the spinner-border class.
* @param {number} [timeoutMs=30000] Timeout duration in milliseconds.
* @param {string|null} [timeoutMessage=null] Message to show when the request times out.
* @documentation https://docs.fossbilling.org/extensions-and-development/javascript/
*/
makeRequest: function (method, url, params, successHandler, errorHandler, enableLoader = true, timeoutMs = 30000, timeoutMessage = null) {
let loader = enableLoader ? this._createLoader() : null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const csrfToken = Tools.getCSRFToken();
const urlObj = new URL(url);
const isSameOrigin = urlObj.origin === window.location.origin;
if (isSameOrigin) {
injectCSRFToken(params, csrfToken);
}
const { body, isFormData } = buildRequestBody(method, params, urlObj);
const headers = buildHeaders({ url: urlObj, body, isFormData, csrfToken, origin: window.location.origin });
const fetchOptions = {
method: method,
headers: headers,
signal: controller.signal
};
if (method.toLowerCase() !== 'get') {
fetchOptions.body = body;
}
return fetch(urlObj.toString(), fetchOptions)
.then(async (response) => {
clearTimeout(timeoutId);
if (response.redirected) {
window.location.replace(response.url);
return;
}
const parsed = await parseResponseBody(response);
return validateHttpResponse(response, parsed);
})
.then((payload) => {
const result = interpretResponse(payload);
if (typeof successHandler === 'function') {
successHandler(result);
}
return result;
})
.catch((error) => {
clearTimeout(timeoutId);
const errorObj = normalizeApiError(error, { timeoutMs, timeoutMessage });
console.error(`API Error: ${errorObj.message}`);
if (typeof errorHandler === 'function') {
errorHandler(errorObj);
} else {
console.warn('No error handler was specified for API error.');
const normalizedError = new Error(errorObj.message);
normalizedError.code = errorObj.code;
throw normalizedError;
}
})
.finally(() => {
if (enableLoader && loader) {
if (loader._fadeInTimeout) {
clearTimeout(loader._fadeInTimeout);
}
if (document.body.contains(loader)) {
document.body.removeChild(loader);
}
loader = null;
}
});
},
/**
* After the API request is complete, this function will be called.
*
* @param {object} object The HTML element that triggered the API call.
* @param {*} result The result of the API call.
* @returns
*/
_afterComplete: function (object, result) {
let apiData;
try {
apiData = Tools.parseDataAttr(object.dataset.fbApi || '{}');
} catch (error) {
console.warn('Invalid JSON in data-fb-api attribute:', error);
return;
}
return completeApiAction(apiData, result);
},
/**
* Creates a loader element and appends it to the document body.
*
* @returns {HTMLElement} The created loader element.
*/
_createLoader: function() {
const loader = document.createElement('div');
loader.className = 'spinner-border fb-request-loader';
loader.setAttribute('role', 'status');
loader.setAttribute('aria-atomic', 'true');
const status = document.createElement('span');
status.className = 'visually-hidden';
const labels = document.querySelector('.toast-container')?.dataset;
status.textContent = labels?.requestLoading || 'Loading. Please wait.';
loader.appendChild(status);
Object.assign(loader.style, {
width: '4rem', height: '4rem', left: '50%', top: '50%',
position: 'fixed', translate: '-50% -50%', zIndex: '1090', pointerEvents: 'none',
opacity: '0', transition: 'opacity 250ms',
});
document.body.appendChild(loader);
loader._fadeInTimeout = setTimeout(() => { loader.style.opacity = '1'; }, 250);
return loader;
},
/**
* Attach event listeners to forms with data attribute 'data-fb-api'.
**/
_apiForm: function () {
const formElements = document.querySelectorAll('form[data-fb-api]');
if (formElements.length > 0) {
formElements.forEach(formElement => {
if (formElement.dataset.fbApiBound === 'true') {
return;
}
formElement.dataset.fbApiBound = 'true';
let requestInProgress = false;
formElement.addEventListener('submit', function (event) {
// Capture-phase validators and extension handlers may cancel submission.
if (event.defaultPrevented) return;
event.preventDefault();
if (requestInProgress) return;
const formData = new FormData(formElement);
const submitter = event.submitter;
if (submitter?.name) {
formData.append(submitter.name, submitter.value);
}
if (FOSSBilling.editor) {
if (!FOSSBilling.editor.validateForm(formElement)) {
const message = 'At least one of the required fields are empty.';
if (FOSSBilling.ui.formError) return FOSSBilling.ui.formError(formElement, message);
return FOSSBilling.ui.notify(message, 'error');
}
FOSSBilling.editor.syncForm(formElement, formData);
}
const formMethod = (formElement.getAttribute('method') || 'post').toLowerCase();
const data = formMethod !== 'get'
? (formElement.enctype === 'multipart/form-data' ? formData : Tools.serializeFormDataToJSON(formData))
: Tools.serializeFormData(formData);
const buttons = [...formElement.elements].filter(control =>
control.matches('button, input[type="submit"], input[type="image"], input[type="button"], input[type="reset"]')
&& !control.disabled);
const toggleButtons = (disable) => {
buttons.forEach(button => button.disabled = disable);
};
const originalBusy = formElement.getAttribute('aria-busy');
let resetComplete = false;
const reset = () => {
if (resetComplete) return;
resetComplete = true;
requestInProgress = false;
toggleButtons(false);
if (originalBusy === null) formElement.removeAttribute('aria-busy');
else formElement.setAttribute('aria-busy', originalBusy);
FOSSBilling.ui.formLoading?.(formElement, false, submitter);
};
const action = formElement.getAttribute('action');
if (!action) {
console.warn('Missing form action attribute. Skipping API call.');
return;
}
FOSSBilling.ui.clearFormError?.(formElement);
requestInProgress = true;
formElement.setAttribute('aria-busy', 'true');
try {
FOSSBilling.ui.formLoading?.(formElement, true, submitter);
toggleButtons(true);
API.makeRequest(
formMethod,
Tools.getBaseURL(action),
data,
(result) => {
try {
FOSSBilling.ui.formSaved?.(formElement);
} finally {
reset();
}
API._afterComplete(formElement, result);
return result;
},
(error) => {
reset();
if (FOSSBilling.ui.formError) FOSSBilling.ui.formError(formElement, error.message);
else FOSSBilling.ui.notify(`${error.message} (${error.code})`, 'error');
},
!FOSSBilling.ui.formLoading
);
} catch (error) {
reset();
throw error;
}
});
});
}
},
/**
* Attach event listeners to links with data attribute 'data-fb-api'.
**/
_apiLink: function () {
const linkElements = document.querySelectorAll('a[data-fb-api]');
if (linkElements.length > 0) {
linkElements.forEach(linkElement => {
if (linkElement.dataset.fbApiBound === 'true') {
return;
}
linkElement.dataset.fbApiBound = 'true';
const loadingState = createLinkLoadingState(linkElement);
linkElement.addEventListener('click', function (event) {
event.preventDefault();
if (loadingState.isInProgress()) {
return;
}
if (linkElement.classList.contains('disabled') || linkElement.getAttribute('aria-disabled') === 'true') {
return;
}
let apiData;
try {
apiData = Tools.parseDataAttr(linkElement.dataset.fbApi || '{}');
} catch (error) {
console.error('Failed to parse data-fb-api attribute:', error);
FOSSBilling.ui.notify('Invalid API configuration', 'error');
return;
}
const rawHref = linkElement.getAttribute('href') || '';
if (!apiData.href && (!rawHref || rawHref === '#')) {
return;
}
const handleApiRequest = (method, href, params = {}) => {
if (apiData.loading || apiData.preventNavigation) {
loadingState.set(apiData);
}
const url = apiData.href || href;
const mergedParams = apiData.params && typeof apiData.params === 'object'
? Object.assign({}, apiData.params, params)
: params;
API.makeRequest(method, Tools.getBaseURL(url), mergedParams,
(result) => {
loadingState.reset();
API._afterComplete(linkElement, result);
},
(error) => {
loadingState.reset();
FOSSBilling.ui.notify(`${error.message} (${error.code})`, 'error');
},
true,
apiData.timeoutMs ?? 30000,
apiData.timeoutMessage ?? null
);
};
const modalsLib = typeof Modals !== 'undefined' ? Modals : null;
dispatchLinkAction(apiData, rawHref, modalsLib, handleApiRequest);
});
});
}
}
};
window.FOSSBilling = window.FOSSBilling || {};
window.FOSSBilling.tools = Tools;
window.FOSSBilling.api = API;
const bindApiInteractions = () => {
if (document.querySelector('form[data-fb-api]')) {
API._apiForm();
}
if (document.querySelector('a[data-fb-api]')) {
API._apiLink();
}
};
if (typeof FOSSBilling.ready === 'function') {
FOSSBilling.ready(bindApiInteractions);
} else if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindApiInteractions);
} else {
bindApiInteractions();
}