signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
479 lines • 18.4 kB
JavaScript
;
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