- sign-in pages load only what they use (include_login_header.php, ajax_core.js) - every library, font and data file served from assets/vendor with pinned versions (VERSIONS.json); no CDN at runtime - Content-Security-Policy on app, sign-in and landing pages - remove httpbin Dropzone target and source-map references
371 lines
12 KiB
JavaScript
371 lines
12 KiB
JavaScript
/**
|
|
* ajax_core.js — helpers every page needs, including the sign-in pages:
|
|
* HTML escaping, form-data collection, the ajax_request() wrapper, the
|
|
* page-wide form-submit guard and live required-field validation.
|
|
*
|
|
* Loaded by include_header.php (before custom.js) and by the minimal
|
|
* login/include_login_header.php, so the sign-in pages no longer download
|
|
* custom.js with every feature's API URLs.
|
|
*/
|
|
|
|
function escape_html(value) {
|
|
return String(value ?? '').replace(/[&<>"']/g, function(c) {
|
|
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
|
});
|
|
}
|
|
|
|
// Text is stored HTML-escaped (see db_statement in dbconn.php), which is right
|
|
// for anything written into markup but wrong inside a form field: a note saved
|
|
// as 5" pipe <spare> came back as 5" pipe <spare>. Field values
|
|
// are never parsed as HTML, so decoding them here is safe.
|
|
function decode_html(value) {
|
|
if (typeof value !== 'string' || value.indexOf('&') === -1) return value;
|
|
return value.replace(/&(quot|#0*39|#x0*27|apos|lt|gt|amp);/gi, function (m, name) {
|
|
name = name.toLowerCase();
|
|
if (name === 'quot') return '"';
|
|
if (name === 'lt') return '<';
|
|
if (name === 'gt') return '>';
|
|
if (name === 'amp') return '&';
|
|
return "'";
|
|
});
|
|
}
|
|
|
|
(function ($) {
|
|
if (!$ || !$.fn || $.fn.val.__decodes_html) return;
|
|
var original_val = $.fn.val;
|
|
$.fn.val = function (value) {
|
|
if (arguments.length && typeof value === 'string') {
|
|
// Only free-text fields; a <select> value must keep matching its option.
|
|
var text_fields = this.filter('input, textarea');
|
|
if (text_fields.length === this.length) return original_val.call(this, decode_html(value));
|
|
}
|
|
return original_val.apply(this, arguments);
|
|
};
|
|
$.fn.val.__decodes_html = true;
|
|
})(window.jQuery);
|
|
|
|
|
|
/** =========================
|
|
* FORMS
|
|
* ========================= */
|
|
|
|
// Prevent all forms from refreshing the page
|
|
$(function () {
|
|
$("form").on("submit", function (e) {
|
|
e.preventDefault();
|
|
});
|
|
});
|
|
|
|
|
|
function prepare_form_data(check_required, raw_data) {
|
|
|
|
var q = {};
|
|
|
|
// Get session context
|
|
const session_element = document.getElementById('session-context');
|
|
|
|
if (session_element) {
|
|
q['company_id'] = session_element.dataset.companyId;
|
|
q['otp'] = session_element.dataset.otp;
|
|
}
|
|
|
|
// Include GET parameters
|
|
const url_params = new URLSearchParams(window.location.search);
|
|
url_params.forEach((value, key) => {
|
|
q[key] = value;
|
|
});
|
|
|
|
// Collect form inputs (exclude search inputs — UI-only filters, not API data)
|
|
$(".form-control:not([type=search]), .form-select").each(function () {
|
|
if (!$(this).attr("id")) return true;
|
|
var el = $(this).get(0);
|
|
if (!el || !el.nodeName) return true;
|
|
q[$(this).attr("id")] = $(this).val();
|
|
});
|
|
|
|
// Validate required fields
|
|
if (check_required === 1) {
|
|
|
|
const required_inputs = document.querySelectorAll('[required]');
|
|
let is_valid = true;
|
|
|
|
required_inputs.forEach(input => {
|
|
if (!input.value.trim()) {
|
|
input.classList.add('is-invalid');
|
|
is_valid = false;
|
|
} else {
|
|
input.classList.remove('is-invalid');
|
|
input.classList.add('is-valid');
|
|
}
|
|
});
|
|
|
|
if (!is_valid) {
|
|
alert("Please fill in all mandatory fields.");
|
|
isAjaxProcessing = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return (raw_data) ? q : JSON.stringify(q);
|
|
}
|
|
|
|
|
|
// server_url is set by include_topbar.php (app pages) and login/include_login_header.php.
|
|
function app_base_url() {
|
|
if (typeof server_url !== 'undefined' && server_url) return server_url;
|
|
return (document.body && document.body.dataset.serverUrl) || '/';
|
|
}
|
|
|
|
/** =========================
|
|
* AJAX WRAPPER
|
|
* ========================= */
|
|
|
|
// Prevent double firing
|
|
let isAjaxProcessing = false;
|
|
|
|
function ajax_request(options) {
|
|
|
|
if (isAjaxProcessing && options.queueLock !== false) {
|
|
// Instead of rejecting, we just return a "never-ending" promise
|
|
// or a resolved promise that does nothing.
|
|
console.warn("Request is busy... ignoring click.");
|
|
return new Promise(() => { }); // This stays pending and won't trigger .then or .catch
|
|
}
|
|
|
|
if (options.queueLock !== false) {
|
|
isAjaxProcessing = true;
|
|
}
|
|
|
|
// Auto prepare form data
|
|
if (options.autoPrepare === true) {
|
|
|
|
let payloadJson = prepare_form_data(options.checkRequired ?? 0, true);
|
|
if (payloadJson === false) {
|
|
isAjaxProcessing = false;
|
|
return Promise.reject("validation_failed");
|
|
}
|
|
|
|
if (options.data) {
|
|
Object.entries(options.data).forEach(([key, value]) => {
|
|
payloadJson[key] = value;
|
|
});
|
|
}
|
|
|
|
if (options.action) {
|
|
// modify action
|
|
if (options.action === 'manage') {
|
|
options.action = (payloadJson['id']) ? 'update' : 'create';
|
|
}
|
|
// add action to JSON
|
|
payloadJson['action'] = options.action;
|
|
} else {
|
|
isAjaxProcessing = false;
|
|
return Promise.reject("please_define_action");
|
|
}
|
|
|
|
options.data = { json: JSON.stringify(payloadJson) };
|
|
|
|
if (options.debugMode) {
|
|
isAjaxProcessing = false;
|
|
// Show FormData contents if applicable
|
|
if (options.formData instanceof FormData) {
|
|
// Log original formData before merging
|
|
for (let [key, value] of options.formData.entries()) {
|
|
console.log("FORMDATA: " + key, value);
|
|
}
|
|
}
|
|
// Show stringified JSON payload
|
|
console.log("REQUEST DATA:", options.data);
|
|
}
|
|
|
|
// IF formData exist, we pass as $_POST [not json]
|
|
if (options.formData instanceof FormData) {
|
|
// THE BYPASS: If formData exists, move all text data into it
|
|
Object.entries(payloadJson).forEach(([key, value]) => {
|
|
options.formData.append(key, value);
|
|
});
|
|
// Override options.data with the full FormData object
|
|
options.data = options.formData;
|
|
}
|
|
|
|
} else if (
|
|
options.data && !(options.data instanceof FormData) &&
|
|
typeof options.data === 'object' && !('json' in options.data)
|
|
) {
|
|
|
|
// autoPrepare: false with a plain field map — e.g. the delete buttons'
|
|
// `data: { id: id }`. db_auth.php only accepts a `json` string or a
|
|
// FormData post carrying `otp`, so an unwrapped map was rejected outright
|
|
// with "Request denied: No valid JSON payload or Form Data detected.", and
|
|
// `options.action` was dropped because only the autoPrepare branch applied
|
|
// it. Wrap it the same way here, without touching callers that already
|
|
// pass a ready-made `{ json: ... }`.
|
|
const session_element = document.getElementById('session-context');
|
|
const payload = {};
|
|
|
|
if (session_element) {
|
|
payload['company_id'] = session_element.dataset.companyId;
|
|
payload['otp'] = session_element.dataset.otp;
|
|
}
|
|
|
|
Object.entries(options.data).forEach(([key, value]) => {
|
|
payload[key] = value;
|
|
});
|
|
|
|
if (options.action) {
|
|
payload['action'] = (options.action === 'manage')
|
|
? (payload['id'] ? 'update' : 'create')
|
|
: options.action;
|
|
}
|
|
|
|
options.data = { json: JSON.stringify(payload) };
|
|
|
|
if (options.debugMode) {
|
|
console.log("REQUEST DATA:", options.data);
|
|
}
|
|
}
|
|
|
|
// --- START MODIFIED $.AJAX BLOCK ---
|
|
let isSendingFiles = (options.data instanceof FormData);
|
|
|
|
// Show loading overlay
|
|
if (options.noLoading !== true) {
|
|
$.LoadingOverlay("show", {
|
|
imageColor: "#525252",
|
|
imageAnimation: "2s rotate_right",
|
|
background: "rgba(255,255,255,0.8)"
|
|
});
|
|
}
|
|
|
|
return $.ajax({
|
|
async: true,
|
|
type: options.type || "POST",
|
|
url: options.url,
|
|
data: options.data,
|
|
dataType: "json",
|
|
// These two settings are only triggered when sending files
|
|
processData: isSendingFiles ? false : true,
|
|
contentType: isSendingFiles ? false : "application/x-www-form-urlencoded; charset=UTF-8",
|
|
// for CSRF validation
|
|
headers: {
|
|
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
|
|
}
|
|
|
|
})
|
|
.then(function (res) {
|
|
|
|
isAjaxProcessing = false;
|
|
if (options.noLoading !== true) $.LoadingOverlay("hide");
|
|
|
|
if (options.debugMode) {
|
|
console.log("RESPONSE:", res);
|
|
return res;
|
|
}
|
|
|
|
if (!res || res.success != 1) {
|
|
if (options.noAlert !== true) bootbox.alert(res?.message || "Unexpected error");
|
|
options.onError?.(null, res?.message || 'api_failed');
|
|
throw new Error(res?.message || "api_failed");
|
|
}
|
|
|
|
options.onSuccess?.(res);
|
|
return res;
|
|
|
|
})
|
|
.catch(function (xhr) {
|
|
|
|
isAjaxProcessing = false;
|
|
$.LoadingOverlay("hide");
|
|
|
|
// Errors re-thrown from .then() — pass through
|
|
if (xhr instanceof Error) {
|
|
throw xhr;
|
|
}
|
|
|
|
// Session displaced — another login took over this account
|
|
if (xhr?.status === 401 && xhr?.responseJSON?.code === 'signed_elsewhere') {
|
|
bootbox.alert('<strong>Signed in from another device.</strong><br>Your session has been ended because this account was signed in elsewhere.', function() {
|
|
window.location.href = app_base_url() + 'index.php';
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Session ended on the server (idle timeout, not signed in, password changed):
|
|
// drop per-tab data and go back to the sign-in form.
|
|
const endedCodes = ['session_expired', 'auth_required', 'password_changed'];
|
|
if (xhr?.status === 401 && endedCodes.includes(xhr?.responseJSON?.code)) {
|
|
try { sessionStorage.clear(); } catch (e) {}
|
|
bootbox.alert(escape_html(xhr.responseJSON.message || 'Please sign in again.'), function() {
|
|
window.location.href = app_base_url() + 'login/index.php';
|
|
});
|
|
return;
|
|
}
|
|
|
|
// File / payload too large (nginx 413)
|
|
if (xhr?.status === 413) {
|
|
bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.');
|
|
options.onError?.(xhr, 'payload_too_large');
|
|
throw xhr;
|
|
}
|
|
|
|
// Usage limit reached — show upgrade notice instead of generic error
|
|
if (xhr?.status === 402) {
|
|
const d = xhr?.responseJSON ?? {};
|
|
const daily = d.daily_limit > 0 ? `Daily: <strong>${d.daily_count} / ${d.daily_limit}</strong>` : null;
|
|
const weekly = d.weekly_limit > 0 ? `Weekly: <strong>${d.weekly_count} / ${d.weekly_limit}</strong>` : null;
|
|
const detail = [daily, weekly].filter(Boolean).join(' | ');
|
|
bootbox.alert(
|
|
`<strong>Usage limit reached.</strong><br>${detail}<br><br>` +
|
|
`Reports are locked until the quota resets. Upgrade your package for higher limits.`
|
|
);
|
|
options.onError?.(xhr, 'limit_reached');
|
|
throw xhr;
|
|
}
|
|
|
|
// Extract server's error message from JSON response
|
|
let serverMessage = xhr?.responseJSON?.message;
|
|
|
|
// Fallback: parse responseText if responseJSON isn't set
|
|
if (!serverMessage && xhr?.responseText) {
|
|
try {
|
|
serverMessage = JSON.parse(xhr.responseText)?.message;
|
|
} catch (e) {
|
|
// Response wasn't JSON — real server crash or HTML error page
|
|
}
|
|
}
|
|
|
|
if (serverMessage) {
|
|
if (options.noAlert !== true) bootbox.alert(serverMessage);
|
|
options.onError?.(xhr, serverMessage);
|
|
} else {
|
|
console.error("AJAX Error:", xhr?.status, xhr?.responseText);
|
|
if (options.noAlert !== true) bootbox.alert("Server error occurred.");
|
|
options.onError?.(xhr, null);
|
|
}
|
|
|
|
throw xhr;
|
|
});
|
|
}
|
|
|
|
|
|
/** =========================
|
|
* REAL-TIME REQUIRED VALIDATION
|
|
* ========================= */
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
const required_inputs = document.querySelectorAll('[required]');
|
|
|
|
required_inputs.forEach(input => {
|
|
input.addEventListener('input', function () {
|
|
if (this.value.trim() !== "") {
|
|
this.classList.remove('is-invalid');
|
|
this.classList.add('is-valid');
|
|
} else {
|
|
this.classList.remove('is-valid');
|
|
this.classList.add('is-invalid');
|
|
}
|
|
});
|
|
});
|
|
|
|
});
|