Self-host front-end libraries, minimal sign-in header and CSP
- 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
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user