Files
NetAlertX/front/js/cache.js
2026-07-25 08:11:08 +10:00

613 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* -----------------------------------------------------------------------------
* NetAlertX
* Open Source Network Guard / WIFI & LAN intrusion detector
*
* cache.js - Front module. Cache primitives, settings, strings, and device
* data caching. Loaded FIRST — no dependencies on other NAX files.
* All cross-file calls (handleSuccess, showSpinner, etc.) are
* call-time dependencies resolved after page load.
*-------------------------------------------------------------------------------
# netalertx@gmail.com GNU GPLv3
----------------------------------------------------------------------------- */
// Cache version stamp — injected by header.php from the app's .VERSION file.
// Changes automatically on every release, busting stale localStorage caches.
// Falls back to a build-time constant so local dev without PHP still works.
const NAX_CACHE_VERSION = (typeof window.NAX_APP_VERSION !== 'undefined')
? window.NAX_APP_VERSION
: 'dev';
// -----------------------------------------------------------------------------
// Central registry of all localStorage cache keys.
// Use these constants (and the helper functions for dynamic keys) everywhere
// instead of bare string literals to prevent silent typo bugs.
// -----------------------------------------------------------------------------
const CACHE_KEYS = {
// --- Init flags (dynamic) ---
// Stores "true" when an AJAX init call completes. Use initFlag(name) below.
initFlag: (name) => `${name}_completed`,
// --- Settings ---
// Stores the value of a setting by its setKey. nax_set_<setKey>
setting: (key) => `nax_set_${key}`,
// Stores the resolved options array for a setting. nax_set_opt_<setKey>
settingOpts: (key) => `nax_set_opt_${key}`,
// --- Language strings ---
// Stores a translated string. pia_lang_<key>_<langCode>
langString: (key, langCode) => `pia_lang_${key}_${langCode}`,
LANG_FALLBACK: 'en_us', // fallback language code
// --- Devices ---
DEVICES_ALL: 'devicesListAll_JSON', // full device list from table_devices.json
DEVICES_TOPOLOGY: 'devicesListNew', // filtered/sorted list for network topology
// --- UI state ---
VISIBLE_MACS: 'ntx_visible_macs', // comma-separated MACs visible in current view
SHOW_ARCHIVED: 'showArchived', // topology show-archived toggle (network page)
SHOW_OFFLINE: 'showOffline', // topology show-offline toggle (network page)
// --- Internal init tracking ---
GRAPHQL_STARTED: 'graphQLServerStarted', // set when GraphQL server responds
STRINGS_COUNT: 'cacheStringsCountCompleted', // count of language packs loaded
STRINGS_LANG: 'cacheStrings_v2_language', // active non-English locale at last load
COMPLETED_CALLS: 'completedCalls', // comma-joined list of completed init calls
INIT_TIMESTAMP: 'nax_init_timestamp', // ms timestamp of last successful cache init
CACHE_VERSION: 'nax_cache_version', // version stamp for auto-bust on deploy
};
// -----------------------------------------------------------------------------
// localStorage cache helpers
// -----------------------------------------------------------------------------
function getCache(key)
{
// check cache
cachedValue = localStorage.getItem(key)
if(cachedValue)
{
return cachedValue;
}
return "";
}
// -----------------------------------------------------------------------------
function setCache(key, data)
{
localStorage.setItem(key, data);
}
// -----------------------------------------------------------------------------
// Fetch data from a server-generated JSON file via query_json.php.
// Returns a Promise resolving with the "data" array from the response.
// -----------------------------------------------------------------------------
function fetchJson(file) {
return new Promise((resolve, reject) => {
$.get('php/server/query_json.php', { file: file, nocache: Date.now() })
.done((res) => resolve(res['data'] || []))
.fail((err) => reject(err));
});
}
// -----------------------------------------------------------------------------
// Safely parse and normalize device cache data.
// Handles both direct array format and { data: [...] } format.
// Returns an array, or empty array on failure.
function parseDeviceCache(cachedStr) {
if (!cachedStr || cachedStr === "") {
return [];
}
let parsed;
try {
parsed = JSON.parse(cachedStr);
} catch (err) {
console.error('[parseDeviceCache] Failed to parse:', err);
return [];
}
// If result is an object with a .data property, extract it (handles legacy format)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.data)) {
console.debug('[parseDeviceCache] Extracting .data property from wrapper object');
parsed = parsed.data;
}
// Ensure result is an array
if (!Array.isArray(parsed)) {
console.error('[parseDeviceCache] Result is not an array:', parsed);
return [];
}
return parsed;
}
// -----------------------------------------------------------------------------
// Returns the API token, base URL, and a ready-to-use Authorization header
// object for all backend API calls. Centralises the repeated
// getSetting("API_TOKEN") + getApiBase() pattern.
// -----------------------------------------------------------------------------
function getAuthContext() {
const token = getSetting('API_TOKEN');
const apiBase = getApiBase();
return {
token,
apiBase,
authHeader: { 'Authorization': 'Bearer ' + token },
};
}
// -----------------------------------------------------------------------------
// Get settings from the .json file generated by the python backend
// and cache them, if available, with options
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// Bootstrap: fetch API_TOKEN and GRAPHQL_PORT directly from app.conf via the
// PHP helper endpoint. Runs before cacheSettings so that API calls made during
// or after init always have a token available — even if table_settings.json
// hasn't been generated yet. Writes values into the setting() namespace so
// getSetting("API_TOKEN") and getSetting("GRAPHQL_PORT") work immediately.
// -----------------------------------------------------------------------------
function cacheApiConfig() {
return new Promise((resolve, reject) => {
if (getCache(CACHE_KEYS.initFlag('cacheApiConfig')) === 'true') {
resolve();
return;
}
$.get('php/server/app_config.php', { nocache: Date.now() })
.done((res) => {
if (res && res.api_token) {
setCache(CACHE_KEYS.setting('API_TOKEN'), res.api_token);
setCache(CACHE_KEYS.setting('GRAPHQL_PORT'), String(res.graphql_port || 20212));
handleSuccess('cacheApiConfig');
resolve();
} else {
console.warn('[cacheApiConfig] Response missing api_token — will rely on cacheSettings fallback');
resolve(); // non-fatal: cacheSettings will still populate these
}
})
.fail((err) => {
console.warn('[cacheApiConfig] Failed to reach app_config.php:', err);
resolve(); // non-fatal fallback
});
});
}
// Module-level declaration so cachePluginStrings() and other callers can
// safely reference pluginsData even before cacheSettings() has fetched it.
// null = fetch not yet attempted or fetch failed (unknown state).
// [] = fetch succeeded and confirmed no plugins are configured.
// [...] = fetch succeeded and plugins are present.
var pluginsData = null;
function cacheSettings()
{
return new Promise((resolve, reject) => {
if(getCache(CACHE_KEYS.initFlag('cacheSettings')) === "true")
{
// Re-populate pluginsData only when cachePluginStrings hasn't completed
// yet (first load after a deploy / migration). Once cachePluginStrings_v1
// is set it early-returns immediately and never reads pluginsData again,
// so the extra fetch would be pointless on every subsequent warm load.
if (getCache(CACHE_KEYS.initFlag('cachePluginStrings_v1')) !== 'true') {
fetchJson('plugins.json')
.then((pluginsArr) => {
// Only update pluginsData on a successful fetch so that a network
// failure is not misread as "no plugins configured" (null sentinel).
if (Array.isArray(pluginsArr)) {
pluginsData = pluginsArr;
}
resolve();
})
.catch(() => {
// Fetch failed — leave pluginsData as null (unknown state) so
// cachePluginStrings() retries rather than treating it as no-op.
resolve();
});
return;
}
resolve();
return;
}
// plugins.json may not exist on first boot — treat its absence as non-fatal
Promise.all([fetchJson('table_settings.json'), fetchJson('plugins.json').catch(() => [])])
.then(([settingsArr, pluginsArr]) => {
pluginsData = pluginsArr;
settingsData = settingsArr;
// Defensive: Accept either array or object with .data property
// for both settings and plugins
if (!Array.isArray(settingsData)) {
if (settingsData && Array.isArray(settingsData.data)) {
settingsData = settingsData.data;
} else {
console.error('[cacheSettings] settingsData is not an array:', settingsData);
reject(new Error('settingsData is not an array'));
return;
}
}
// Normalize plugins array too (may have { data: [...] } format)
if (!Array.isArray(pluginsData)) {
if (pluginsData && Array.isArray(pluginsData.data)) {
pluginsData = pluginsData.data;
} else {
console.warn('[cacheSettings] pluginsData is not an array, treating as empty');
pluginsData = [];
}
}
settingsData.forEach((set) => {
resolvedOptions = createArray(set.setOptions)
resolvedOptionsOld = resolvedOptions
setPlugObj = {};
options_params = [];
resolved = ""
// proceed only if first option item contains something to resolve
if( !set.setKey.includes("__metadata") &&
resolvedOptions.length != 0 &&
resolvedOptions[0].includes("{value}"))
{
// get setting definition from the plugin config if available
setPlugObj = getPluginSettingObject(pluginsData, set.setKey)
// check if options contains parameters and resolve
if(setPlugObj != {} && setPlugObj["options_params"])
{
// get option_params for {value} resolution
options_params = setPlugObj["options_params"]
if(options_params != [])
{
// handles only strings of length == 1
resolved = resolveParams(options_params, resolvedOptions[0])
if(resolved.includes('"')) // check if list of strings
{
resolvedOptions = `[${resolved}]`
} else // one value only
{
resolvedOptions = `["${resolved}"]`
}
}
}
}
setCache(CACHE_KEYS.setting(set.setKey), set.setValue)
setCache(CACHE_KEYS.settingOpts(set.setKey), resolvedOptions)
});
handleSuccess('cacheSettings');
resolve();
})
.catch((err) => { handleFailure('cacheSettings'); reject(err); });
});
}
// -----------------------------------------------------------------------------
// Get a setting options value by key
function getSettingOptions (key) {
result = getCache(CACHE_KEYS.settingOpts(key));
if (result == "")
{
result = []
}
return result;
}
// -----------------------------------------------------------------------------
// Get a setting value by key
function getSetting (key) {
result = getCache(CACHE_KEYS.setting(key));
return result;
}
// -----------------------------------------------------------------------------
// Get language string
// -----------------------------------------------------------------------------
function cacheStrings() {
return new Promise((resolve, reject) => {
if(getCache(CACHE_KEYS.initFlag('cacheStrings_v2')) === "true")
{
// Verify STRINGS_COUNT and the stored language both match the current
// configuration. A count-only check allows a locale switch between two
// non-English languages (both count=2) to pass undetected, leaving the
// UI with stale strings for the old language.
const activeLang = getLangCode();
const expectedCount = activeLang === 'en_us' ? 1 : 2;
const currentCount = parseInt(getCache(CACHE_KEYS.STRINGS_COUNT)) || 0;
const storedLang = getCache(CACHE_KEYS.STRINGS_LANG) || 'en_us';
if (currentCount === expectedCount && storedLang === activeLang) {
resolve();
return;
}
// Mismatch — reset count and fall through to reload strings for the
// current language set. The flag stays set; handleSuccess re-increments.
console.log(`[cacheStrings] stale cache (count: ${currentCount}/${expectedCount}, lang: '${storedLang}' → '${activeLang}') — reloading strings.`);
setCache(CACHE_KEYS.STRINGS_COUNT, 0);
// Do NOT return — fall through to full load path below.
}
// Create a promise for each language (include en_us by default as fallback)
languagesToLoad = ['en_us']
additionalLanguage = getLangCode()
if(additionalLanguage != 'en_us')
{
languagesToLoad.push(additionalLanguage)
}
console.log('[cacheStrings] Languages to load:', languagesToLoad);
const languagePromises = languagesToLoad.map((language_code) => {
return new Promise((resolveLang, rejectLang) => {
$.get(`php/templates/language/${language_code}.json?nocache=${Date.now()}`)
.done((res) => {
const entries = Object.entries(res);
entries.forEach(([key, value]) => {
setCache(CACHE_KEYS.langString(key, language_code), value);
});
console.log(`[cacheStrings] Loaded ${entries.length} core strings for language '${language_code}'.`);
handleSuccess('cacheStrings_v2');
resolveLang();
})
.fail((error) => {
rejectLang(error);
});
});
});
// Wait for all language promises to complete
Promise.all(languagePromises)
.then(() => {
// Record the active language so the early-return check can detect
// a locale switch between two non-English languages (both count=2).
setCache(CACHE_KEYS.STRINGS_LANG, getLangCode());
resolve();
})
.catch((error) => {
handleFailure('cacheStrings_v2');
reject(error);
});
});
}
// -----------------------------------------------------------------------------
// Fetch and cache plugin-specific language strings from table_plugins_language_strings.json.
// Runs as a separate init step so that a missing or empty file triggers a retry
// via retryStep(), instead of silently marking initialization complete.
// Resolves immediately if no plugins are loaded (pluginsData is empty).
// -----------------------------------------------------------------------------
function cachePluginStrings() {
return new Promise((resolve, reject) => {
if (getCache(CACHE_KEYS.initFlag('cachePluginStrings_v1')) === 'true') {
resolve();
return;
}
fetchJson('table_plugins_language_strings.json')
.catch((err) => {
console.warn('[cachePluginStrings] Plugin language strings fetch failed:', err);
return [];
})
.then((data) => {
if (!Array.isArray(data)) { data = []; }
if (data.length === 0) {
// No plugin strings returned. If plugins are loaded the file may not
// be ready yet — reject so retryStep() retries. If no plugins are
// loaded at all, empty strings are expected: succeed.
// null = plugins.json fetch failed (unknown) — retry to be safe.
// [] = confirmed no plugins — accept empty strings as valid.
// [...] = plugins present — retry, file may not be ready yet.
const hasPlugins = pluginsData === null || (Array.isArray(pluginsData) && pluginsData.length > 0);
if (hasPlugins) {
console.warn('[cachePluginStrings] 0 entries returned but plugins are loaded — file may not be ready yet. Will retry.');
reject(new Error('Plugin language strings empty'));
} else {
console.log('[cachePluginStrings] No plugins loaded — skipping plugin string cache.');
handleSuccess('cachePluginStrings_v1');
resolve();
}
return;
}
data.forEach((langString) => {
setCache(CACHE_KEYS.langString(langString.stringKey, langString.languageCode), langString.stringValue);
});
console.log(`[cachePluginStrings] Loaded ${data.length} plugin language string entries.`);
handleSuccess('cachePluginStrings_v1');
resolve();
});
});
}
// -----------------------------------------------------------------------------
// Get translated language string
function getString(key) {
function fetchString(key) {
lang_code = getLangCode();
let result = getCache(CACHE_KEYS.langString(key, lang_code));
if (isEmpty(result)) {
result = getCache(CACHE_KEYS.langString(key, CACHE_KEYS.LANG_FALLBACK));
}
return result;
}
if (isAppInitialized()) {
return fetchString(key);
} else {
callAfterAppInitialized(() => fetchString(key));
}
}
// -----------------------------------------------------------------------------
// Get current language ISO code.
// The UI_LANG setting value is always in the form "Name (code)", e.g. "English (en_us)".
// Extracting the code with a regex means this function never needs updating when a
// new language is added — the single source of truth is languages.json.
function getLangCode() {
UI_LANG = getSetting("UI_LANG");
const match = (UI_LANG || '').match(/\(([a-z]{2}_[a-z]{2})\)\s*$/i);
return match ? match[1].toLowerCase() : 'en_us';
}
// -----------------------------------------------------------------------------
// A function to get a device property using the mac address as key and DB column name as parameter
// for the value to be returned
function getDevDataByMac(macAddress, dbColumn) {
const sessionDataKey = CACHE_KEYS.DEVICES_ALL;
const devicesCache = getCache(sessionDataKey);
if (!devicesCache || devicesCache == "") {
console.warn(`[getDevDataByMac] Cache key "${sessionDataKey}" is empty — cache may not be initialized yet.`);
return null;
}
const devices = parseDeviceCache(devicesCache);
if (devices.length === 0) {
return null;
}
for (const device of devices) {
if (device["devMac"].toLowerCase() === macAddress.toLowerCase()) {
if(dbColumn)
{
return device[dbColumn];
}
else
{
return device
}
}
}
console.error("⚠ Device with MAC not found:" + macAddress)
return null; // Return a default value if MAC address is not found
}
// -----------------------------------------------------------------------------
// Get a device property using GUID as key and DB column name as parameter
function getDevDataByGuid(devGUID, dbColumn) {
const sessionDataKey = CACHE_KEYS.DEVICES_ALL;
const devicesCache = getCache(sessionDataKey);
if (!devicesCache || devicesCache == "") {
console.warn(`[getDevDataByGuid] Cache key "${sessionDataKey}" is empty — cache may not be initialized yet.`);
return null;
}
const devices = parseDeviceCache(devicesCache);
if (devices.length === 0) {
return null;
}
const guid = devGUID?.toLowerCase();
for (const device of devices) {
if ((device["devGUID"] || "").toLowerCase() === guid) {
if (dbColumn) {
return device[dbColumn];
}
return device;
}
}
console.error("⚠ Device with GUID not found: " + devGUID);
return null;
}
// -----------------------------------------------------------------------------
/**
* Fetches the full device list from table_devices.json and stores it in
* localStorage under CACHE_KEYS.DEVICES_ALL.
*
* On subsequent calls the fetch is skipped if the initFlag is already set,
* unless forceRefresh is true. Pass forceRefresh = true whenever the caller
* knows the cached list may be stale (e.g. a device was not found by MAC and
* the page needs to recover without a full clearCache()).
*
* @param {boolean} [forceRefresh=false] - When true, bypasses the initFlag
* guard and always fetches fresh data from the server.
* @returns {Promise<void>} Resolves when the cache has been populated.
*/
function cacheDevices(forceRefresh = false)
{
return new Promise((resolve, reject) => {
if(!forceRefresh && getCache(CACHE_KEYS.initFlag('cacheDevices')) === "true")
{
// One-time migration: normalize legacy { data: [...] } wrapper to a plain array.
// Old cache entries from prior versions stored the raw API envelope; re-write
// them in the flat format so parseDeviceCache never needs the fallback branch.
const raw = getCache(CACHE_KEYS.DEVICES_ALL);
if (raw) {
try {
const p = JSON.parse(raw);
if (p && typeof p === 'object' && !Array.isArray(p) && Array.isArray(p.data)) {
setCache(CACHE_KEYS.DEVICES_ALL, JSON.stringify(p.data));
}
} catch (e) { /* ignore malformed cache will be refreshed next init */ }
}
resolve();
return;
}
fetchJson('table_devices.json')
.then((arr) => {
devicesListAll_JSON = arr;
devicesListAll_JSON_str = JSON.stringify(devicesListAll_JSON)
if(devicesListAll_JSON_str == "")
{
showSpinner()
setTimeout(() => {
cacheDevices()
}, 1000);
}
setCache(CACHE_KEYS.DEVICES_ALL, devicesListAll_JSON_str)
handleSuccess('cacheDevices');
resolve();
})
.catch((err) => { handleFailure('cacheDevices'); reject(err); });
}
);
}
var devicesListAll_JSON = []; // this will contain a list off all devices