UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

479 lines 18.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.DEFAULT_PRESET = void 0; exports.setApplicationDataPath = setApplicationDataPath; exports.getConfigUnitprefsDir = getConfigUnitprefsDir; exports.loadAll = loadAll; exports.getCategories = getCategories; exports.getCustomCategories = getCustomCategories; exports.getStandardDefinitions = getStandardDefinitions; exports.getCustomDefinitions = getCustomDefinitions; exports.getActivePreset = getActivePreset; exports.getConfig = getConfig; exports.reloadPreset = reloadPreset; exports.invalidatePresetCache = invalidatePresetCache; exports.reloadCustomDefinitions = reloadCustomDefinitions; exports.reloadCustomCategories = reloadCustomCategories; exports.getMergedDefinitions = getMergedDefinitions; exports.getDefaultCategory = getDefaultCategory; exports.getBaseUnitToCategories = getBaseUnitToCategories; exports.getCategoryForBaseUnit = getCategoryForBaseUnit; exports.loadUserPreferences = loadUserPreferences; exports.saveUserPreferences = saveUserPreferences; exports.getActivePresetForUser = getActivePresetForUser; const fs = __importStar(require("fs")); const path = __importStar(require("path")); const atomicWrite_1 = require("../atomicWrite"); const debug_1 = require("../debug"); const debug = (0, debug_1.createDebug)('signalk-server:unitpreferences:loader'); const PACKAGE_UNITPREFS_DIR = path.join(__dirname, '../../unitpreferences'); exports.DEFAULT_PRESET = 'nautical-metric'; const VALID_USERNAME = /^[a-zA-Z0-9_.\-@]+$/; const USER_PREFS_FILE = '1.0.0.json'; let categories; let customCategories = {}; let standardDefinitions; let customDefinitions; let activePreset; let config; let defaultCategories = {}; let defaultCategoryWildcards = []; let baseUnitToCategoriesCache = null; let mergedDefinitionsCache = null; let categoriesCache = null; const userPreferencesCache = new Map(); const presetByNameCache = new Map(); let applicationDataPath = ''; let configUnitprefsDir = ''; let defaultPrimaryCategories = {}; function validateUsername(username) { if (!VALID_USERNAME.test(username) || username === '.' || username === '..') { throw new Error(`Invalid username: ${username}`); } } function getUserPrefsPath(username) { validateUsername(username); const result = path.join(applicationDataPath, 'users', username, 'unitpreferences', USER_PREFS_FILE); const resolved = path.resolve(result); const usersRoot = path.resolve(applicationDataPath, 'users') + path.sep; if (!resolved.startsWith(usersRoot)) { throw new Error(`Invalid username path: ${username}`); } return result; } function setApplicationDataPath(configPath) { applicationDataPath = path.join(configPath, 'applicationData'); configUnitprefsDir = path.join(configPath, 'unitpreferences'); ensureConfigDir(); } function getConfigUnitprefsDir() { return configUnitprefsDir; } function ensureConfigDir() { if (!configUnitprefsDir) return; // Create directory structure fs.mkdirSync(path.join(configUnitprefsDir, 'presets', 'custom'), { recursive: true }); // Seed mutable files from package dir if not present in config dir const mutableFiles = [ 'config.json', 'custom-units-definitions.json', 'custom-categories.json' ]; for (const file of mutableFiles) { const destPath = path.join(configUnitprefsDir, file); if (!fs.existsSync(destPath)) { const srcPath = path.join(PACKAGE_UNITPREFS_DIR, file); if (fs.existsSync(srcPath)) { fs.copyFileSync(srcPath, destPath); } } } // Migrate custom presets from package dir if any exist const pkgCustomDir = path.join(PACKAGE_UNITPREFS_DIR, 'presets', 'custom'); if (fs.existsSync(pkgCustomDir)) { const configCustomDir = path.join(configUnitprefsDir, 'presets', 'custom'); for (const file of fs.readdirSync(pkgCustomDir)) { if (file.endsWith('.json')) { const destPath = path.join(configCustomDir, file); if (!fs.existsSync(destPath)) { fs.copyFileSync(path.join(pkgCustomDir, file), destPath); } } } } } function loadAll() { // Load categories (read-only, from package) categories = JSON.parse(fs.readFileSync(path.join(PACKAGE_UNITPREFS_DIR, 'categories.json'), 'utf-8')); // Load standard definitions (read-only, from package) standardDefinitions = JSON.parse(fs.readFileSync(path.join(PACKAGE_UNITPREFS_DIR, 'standard-units-definitions.json'), 'utf-8')); // Load custom definitions from config dir const customPath = configUnitprefsDir ? path.join(configUnitprefsDir, 'custom-units-definitions.json') : path.join(PACKAGE_UNITPREFS_DIR, 'custom-units-definitions.json'); if (fs.existsSync(customPath)) { customDefinitions = JSON.parse(fs.readFileSync(customPath, 'utf-8')); } else { customDefinitions = {}; } // Load custom categories from config dir const customCatPath = configUnitprefsDir ? path.join(configUnitprefsDir, 'custom-categories.json') : path.join(PACKAGE_UNITPREFS_DIR, 'custom-categories.json'); if (fs.existsSync(customCatPath)) { customCategories = JSON.parse(fs.readFileSync(customCatPath, 'utf-8')); } else { customCategories = {}; } // Load config from config dir const cfgPath = configUnitprefsDir ? path.join(configUnitprefsDir, 'config.json') : path.join(PACKAGE_UNITPREFS_DIR, 'config.json'); if (fs.existsSync(cfgPath)) { config = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); } else { config = { activePreset: exports.DEFAULT_PRESET }; } // Load default categories (read-only, from package) const defaultCatPath = path.join(PACKAGE_UNITPREFS_DIR, 'default-categories.json'); if (fs.existsSync(defaultCatPath)) { const defaultCatData = JSON.parse(fs.readFileSync(defaultCatPath, 'utf-8')); // Build flat lookup: path -> category defaultCategories = {}; for (const [categoryName, catDef] of Object.entries(defaultCatData.categories || {})) { const def = catDef; for (const p of def.paths || []) { defaultCategories[p] = categoryName; } } } rebuildDefaultCategoryWildcards(); // Load default primary categories (read-only, from package) const primaryCatPath = path.join(PACKAGE_UNITPREFS_DIR, 'primary-categories.json'); if (fs.existsSync(primaryCatPath)) { defaultPrimaryCategories = JSON.parse(fs.readFileSync(primaryCatPath, 'utf-8')); } // Invalidate caches baseUnitToCategoriesCache = null; mergedDefinitionsCache = null; categoriesCache = null; userPreferencesCache.clear(); presetByNameCache.clear(); // Load active preset loadActivePreset(); } // Wildcard '*' matches a single signalk path segment (no dots). // Example: 'electrical.batteries.*.voltage' matches 'electrical.batteries.house.voltage' // but not 'electrical.batteries.house.voltage.value'. function rebuildDefaultCategoryWildcards() { defaultCategoryWildcards = []; for (const [pattern, category] of Object.entries(defaultCategories)) { if (pattern.includes('*')) { const regex = new RegExp('^' + pattern .replace(/[\\^$+?()[\]{}|]/g, '\\$&') .replace(/\./g, '\\.') .replace(/\*/g, '[^.]+') + '$'); defaultCategoryWildcards.push({ regex, category }); } } } function loadActivePreset() { const presetName = config.activePreset; // Check custom presets in config dir first if (configUnitprefsDir) { const customPresetPath = path.join(configUnitprefsDir, 'presets/custom', `${presetName}.json`); if (fs.existsSync(customPresetPath)) { activePreset = JSON.parse(fs.readFileSync(customPresetPath, 'utf-8')); return; } } // Fall back to built-in presets in package dir const builtInPath = path.join(PACKAGE_UNITPREFS_DIR, 'presets', `${presetName}.json`); if (fs.existsSync(builtInPath)) { activePreset = JSON.parse(fs.readFileSync(builtInPath, 'utf-8')); return; } // Default to nautical-metric activePreset = JSON.parse(fs.readFileSync(path.join(PACKAGE_UNITPREFS_DIR, `presets/${exports.DEFAULT_PRESET}.json`), 'utf-8')); } // The returned object is shared and must be treated as read-only by callers. // Invalidated by loadAll(), reloadCustomCategories(). function getCategories() { if (categoriesCache) { return categoriesCache; } const merged = { ...categories }; merged.categoryToBaseUnit = { ...categories.categoryToBaseUnit, ...customCategories }; categoriesCache = merged; return categoriesCache; } function getCustomCategories() { return customCategories; } function getStandardDefinitions() { return standardDefinitions; } function getCustomDefinitions() { return customDefinitions; } function getActivePreset() { return activePreset; } function getConfig() { return config; } function reloadPreset() { presetByNameCache.clear(); // Re-read config from config dir const cfgPath = configUnitprefsDir ? path.join(configUnitprefsDir, 'config.json') : path.join(PACKAGE_UNITPREFS_DIR, 'config.json'); if (fs.existsSync(cfgPath)) { config = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); } loadActivePreset(); } // Drop a single preset (or all) from the cache. Call after writing or // deleting a custom preset file directly so subsequent loads see the new // content. function invalidatePresetCache(presetName) { if (presetName === undefined) { presetByNameCache.clear(); } else { presetByNameCache.delete(presetName); } } function reloadCustomDefinitions() { mergedDefinitionsCache = null; const customPath = configUnitprefsDir ? path.join(configUnitprefsDir, 'custom-units-definitions.json') : path.join(PACKAGE_UNITPREFS_DIR, 'custom-units-definitions.json'); if (fs.existsSync(customPath)) { customDefinitions = JSON.parse(fs.readFileSync(customPath, 'utf-8')); } else { customDefinitions = {}; } } function reloadCustomCategories() { baseUnitToCategoriesCache = null; categoriesCache = null; const customCatPath = configUnitprefsDir ? path.join(configUnitprefsDir, 'custom-categories.json') : path.join(PACKAGE_UNITPREFS_DIR, 'custom-categories.json'); if (fs.existsSync(customCatPath)) { customCategories = JSON.parse(fs.readFileSync(customCatPath, 'utf-8')); } else { customCategories = {}; } } // The returned object is shared and must be treated as read-only by callers. // Invalidated by loadAll(), reloadCustomDefinitions(). function getMergedDefinitions() { if (mergedDefinitionsCache) { return mergedDefinitionsCache; } // Custom definitions override standard const merged = structuredClone(standardDefinitions); for (const [siUnit, def] of Object.entries(customDefinitions)) { if (!merged[siUnit]) { merged[siUnit] = def; } else { merged[siUnit].conversions = { ...merged[siUnit].conversions, ...def.conversions }; } } mergedDefinitionsCache = merged; return mergedDefinitionsCache; } function getDefaultCategory(signalkPath, pathSiUnit, username) { if (defaultCategories[signalkPath]) { return defaultCategories[signalkPath]; } for (const { regex, category } of defaultCategoryWildcards) { if (regex.test(signalkPath)) { return category; } } if (pathSiUnit) { return getCategoryForBaseUnit(pathSiUnit, username); } return null; } function getBaseUnitToCategories() { if (!baseUnitToCategoriesCache) { // Object.create(null): baseUnit values come from admin-controlled // custom-categories.json and become keys here; a null-prototype map // is immune to __proto__/constructor pollution. const cache = Object.create(null); const cats = getCategories(); for (const [category, baseUnit] of Object.entries(cats.categoryToBaseUnit)) { if (!cache[baseUnit]) { cache[baseUnit] = []; } cache[baseUnit].push(category); } baseUnitToCategoriesCache = cache; } return baseUnitToCategoriesCache; } function getCategoryForBaseUnit(baseUnit, username) { const map = getBaseUnitToCategories(); const matchingCategories = map[baseUnit]; if (!matchingCategories || matchingCategories.length === 0) return null; if (matchingCategories.length === 1) return matchingCategories[0]; if (username) { const userPrefs = loadUserPreferences(username); if (userPrefs?.primaryCategories?.[baseUnit]) { const userPrimary = userPrefs.primaryCategories[baseUnit]; if (matchingCategories.includes(userPrimary)) return userPrimary; } } const defaultPrimary = defaultPrimaryCategories[baseUnit]; if (defaultPrimary && matchingCategories.includes(defaultPrimary)) return defaultPrimary; // Deterministic fallback when neither user nor system default resolves. return [...matchingCategories].sort()[0]; } function loadUserPreferences(username) { if (!applicationDataPath) return null; const cached = userPreferencesCache.get(username); if (cached !== undefined) { return cached === null ? null : structuredClone(cached); } try { const userPrefPath = getUserPrefsPath(username); if (fs.existsSync(userPrefPath)) { const prefs = JSON.parse(fs.readFileSync(userPrefPath, 'utf-8')); userPreferencesCache.set(username, prefs); return structuredClone(prefs); } } catch (err) { const code = err.code; if (code !== 'ENOENT') { debug('Error reading user preferences for %s: %O', username, err); } } userPreferencesCache.set(username, null); return null; } function saveUserPreferences(username, prefs) { if (!applicationDataPath) throw new Error('applicationDataPath not set'); const filePath = getUserPrefsPath(username); const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); (0, atomicWrite_1.atomicWriteFileSync)(filePath, JSON.stringify(prefs, null, 2)); userPreferencesCache.set(username, structuredClone(prefs)); } // Cached results are shared and must be treated as read-only by callers. // Invalidated by loadAll(), reloadPreset(), invalidatePresetCache(). function loadPresetByName(presetName) { // Reject malformed names before touching the cache so attacker-controlled // values from user prefs cannot grow the Map with junk keys. if (!/^[a-zA-Z0-9_-]+$/.test(presetName)) { return null; } const cached = presetByNameCache.get(presetName); if (cached !== undefined) { return cached; } const preset = readPresetFromDisk(presetName); presetByNameCache.set(presetName, preset); return preset; } function readPresetFromDisk(presetName) { // Check custom presets in config dir first if (configUnitprefsDir) { const customPresetPath = path.join(configUnitprefsDir, 'presets/custom', `${presetName}.json`); if (fs.existsSync(customPresetPath)) { return JSON.parse(fs.readFileSync(customPresetPath, 'utf-8')); } } // Fall back to built-in presets in package dir const builtInPath = path.join(PACKAGE_UNITPREFS_DIR, 'presets', `${presetName}.json`); if (fs.existsSync(builtInPath)) { return JSON.parse(fs.readFileSync(builtInPath, 'utf-8')); } return null; } function getActivePresetForUser(username) { // 1. Check applicationData for user's preset preference if (username) { const userPref = loadUserPreferences(username); if (userPref?.activePreset) { const preset = loadPresetByName(userPref.activePreset); if (preset) return preset; } } // 2. User-specific preset from config (legacy) if (username && config.userPresets?.[username]) { const preset = loadPresetByName(config.userPresets[username]); if (preset) return preset; } // 3. Admin preset if (config.activePreset) { const preset = loadPresetByName(config.activePreset); if (preset) return preset; } // 4. Default return loadPresetByName(exports.DEFAULT_PRESET) || activePreset; } //# sourceMappingURL=loader.js.map