signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
630 lines • 26.2 kB
JavaScript
"use strict";
const express = require('express');
const fs = require('fs');
const path = require('path');
const { atomicWriteFileSync } = require('../atomicWrite');
const busboy = require('busboy');
const { compile } = require('mathjs');
const { createDebug } = require('../debug');
const debug = createDebug('signalk-server:unitpreferences-api');
/**
* Validate a formula string by attempting to compile it with mathjs
* @param {string} formula - The formula to validate
* @returns {string|null} - Error message if invalid, null if valid
*/
function validateFormula(formula) {
try {
compile(formula);
return null;
}
catch (e) {
return e.message;
}
}
const VALID_PRESET_NAME = /^[a-zA-Z0-9_-]+$/;
/**
* Validate all formulas in a unit definitions object
* @param {object} definitions - The unit definitions to validate
* @returns {string|null} - Error message if any formula is invalid, null if all valid
*/
function validateDefinitions(definitions) {
for (const [siUnit, def] of Object.entries(definitions)) {
if (def.conversions) {
for (const [targetUnit, conversion] of Object.entries(def.conversions)) {
if (conversion.formula) {
const error = validateFormula(conversion.formula);
if (error) {
return `Invalid formula for ${siUnit} -> ${targetUnit}: ${error}`;
}
}
if (conversion.inverseFormula) {
const error = validateFormula(conversion.inverseFormula);
if (error) {
return `Invalid inverseFormula for ${siUnit} -> ${targetUnit}: ${error}`;
}
}
}
}
}
return null;
}
/**
* Validate a preset JSON structure thoroughly
* @param {object} preset - The preset object to validate
* @param {object} definitions - Unit definitions for validation
* @returns {string|null} - Error message if invalid, null if valid
*/
function validatePreset(preset, definitions) {
const errors = [];
if (!preset || typeof preset !== 'object' || Array.isArray(preset)) {
return 'Preset must be a JSON object';
}
if (!preset.version || typeof preset.version !== 'string') {
return 'Missing or invalid "version"';
}
if (!preset.name || typeof preset.name !== 'string') {
return 'Missing or invalid "name"';
}
if (!preset.categories ||
typeof preset.categories !== 'object' ||
Array.isArray(preset.categories)) {
return 'Missing or invalid "categories"';
}
if (Object.keys(preset.categories).length === 0) {
return 'categories cannot be empty';
}
for (const [name, cat] of Object.entries(preset.categories)) {
if (!cat || typeof cat !== 'object') {
errors.push(`"${name}": must be an object`);
continue;
}
if (!cat.baseUnit) {
errors.push(`"${name}": missing baseUnit`);
continue;
}
if (!cat.targetUnit) {
errors.push(`"${name}": missing targetUnit`);
continue;
}
// Skip datetime validation for now - datetime formats are handled differently
const datetimeBaseUnits = [
'RFC 3339 (UTC)',
'ISO-8601 (UTC)',
'Epoch Seconds'
];
if (datetimeBaseUnits.includes(cat.baseUnit)) {
continue;
}
const unitDef = definitions[cat.baseUnit];
if (!unitDef) {
errors.push(`"${name}": unknown baseUnit "${cat.baseUnit}"`);
continue;
}
// targetUnit same as baseUnit means no conversion, which is valid
if (cat.targetUnit !== cat.baseUnit) {
if (!unitDef.conversions || !unitDef.conversions[cat.targetUnit]) {
const available = Object.keys(unitDef.conversions || {}).join(', ');
errors.push(`"${name}": "${cat.targetUnit}" not in ${cat.baseUnit} conversions. Available: ${available}`);
}
}
}
if (errors.length > 0) {
return errors.join('\n');
}
return null;
}
const { getConfig, getCategories, getCustomCategories, getActivePreset, getMergedDefinitions, reloadPreset, reloadCustomDefinitions, reloadCustomCategories, invalidatePresetCache, getDefaultCategory, getBaseUnitToCategories, getCategoryForBaseUnit, loadUserPreferences, saveUserPreferences } = require('../unitpreferences');
const PACKAGE_UNITPREFS_DIR = path.join(__dirname, '../../unitpreferences');
module.exports = function (app) {
const configUnitprefsDir = path.join(app.config.configPath, 'unitpreferences');
function checkPresetExists(presetName) {
const builtInPath = path.join(PACKAGE_UNITPREFS_DIR, 'presets', `${presetName}.json`);
if (fs.existsSync(builtInPath)) {
return { type: 'builtin' };
}
const customPath = path.join(configUnitprefsDir, 'presets/custom', `${presetName}.json`);
if (fs.existsSync(customPath)) {
return { type: 'custom' };
}
return null;
}
const router = express.Router();
// GET /signalk/v1/unitpreferences/config
router.get('/config', (req, res) => {
try {
const config = getConfig();
res.json(config);
}
catch (err) {
debug('Error getting config:', err);
res.status(500).json({ error: 'Failed to get config' });
}
});
// PUT /signalk/v1/unitpreferences/config
router.put('/config', (req, res) => {
try {
const configPath = path.join(configUnitprefsDir, 'config.json');
atomicWriteFileSync(configPath, JSON.stringify(req.body, null, 2));
reloadPreset();
app.emit('unitpreferencesChanged', { type: 'global' });
res.json({ success: true });
}
catch (err) {
debug('Error saving config:', err);
res.status(500).json({ error: 'Failed to save config' });
}
});
// GET /signalk/v1/unitpreferences/categories
router.get('/categories', (req, res) => {
try {
const categories = getCategories();
res.json(categories);
}
catch (err) {
debug('Error getting categories:', err);
res.status(500).json({ error: 'Failed to get categories' });
}
});
// GET /signalk/v1/unitpreferences/definitions
router.get('/definitions', (req, res) => {
try {
const definitions = getMergedDefinitions();
res.json(definitions);
}
catch (err) {
debug('Error getting definitions:', err);
res.status(500).json({ error: 'Failed to get definitions' });
}
});
// GET /signalk/v1/unitpreferences/custom-definitions
router.get('/custom-definitions', (req, res) => {
try {
const customPath = path.join(configUnitprefsDir, 'custom-units-definitions.json');
if (fs.existsSync(customPath)) {
const data = JSON.parse(fs.readFileSync(customPath, 'utf-8'));
res.json(data);
}
else {
res.json({});
}
}
catch (err) {
debug('Error getting custom definitions:', err);
res.status(500).json({ error: 'Failed to get custom definitions' });
}
});
// PUT /signalk/v1/unitpreferences/custom-definitions
router.put('/custom-definitions', (req, res) => {
try {
// Validate all formulas before saving
const validationError = validateDefinitions(req.body);
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
const customPath = path.join(configUnitprefsDir, 'custom-units-definitions.json');
atomicWriteFileSync(customPath, JSON.stringify(req.body, null, 2));
reloadCustomDefinitions();
app.emit('unitpreferencesChanged', { type: 'global' });
res.json({ success: true });
}
catch (err) {
debug('Error saving custom definitions:', err);
res.status(500).json({ error: 'Failed to save custom definitions' });
}
});
// GET /signalk/v1/unitpreferences/custom-categories
router.get('/custom-categories', (req, res) => {
try {
const customCats = getCustomCategories();
res.json(customCats);
}
catch (err) {
debug('Error getting custom categories:', err);
res.status(500).json({ error: 'Failed to get custom categories' });
}
});
// PUT /signalk/v1/unitpreferences/custom-categories
router.put('/custom-categories', (req, res) => {
try {
const customPath = path.join(configUnitprefsDir, 'custom-categories.json');
atomicWriteFileSync(customPath, JSON.stringify(req.body, null, 2));
reloadCustomCategories();
app.emit('unitpreferencesChanged', { type: 'global' });
res.json({ success: true });
}
catch (err) {
debug('Error saving custom categories:', err);
res.status(500).json({ error: 'Failed to save custom categories' });
}
});
// GET /signalk/v1/unitpreferences/presets
router.get('/presets', (req, res) => {
try {
const presetsDir = path.join(PACKAGE_UNITPREFS_DIR, 'presets');
const customDir = path.join(configUnitprefsDir, 'presets', 'custom');
const builtIn = [];
const custom = [];
// List built-in presets from package dir
const builtInFiles = fs.readdirSync(presetsDir);
for (const file of builtInFiles) {
if (file.endsWith('.json')) {
const presetPath = path.join(presetsDir, file);
const preset = JSON.parse(fs.readFileSync(presetPath, 'utf-8'));
builtIn.push({
name: file.replace('.json', ''),
displayName: preset.name,
description: preset.description
});
}
}
// List custom presets from config dir
if (fs.existsSync(customDir)) {
const customFiles = fs.readdirSync(customDir);
for (const file of customFiles) {
if (file.endsWith('.json')) {
const presetPath = path.join(customDir, file);
const preset = JSON.parse(fs.readFileSync(presetPath, 'utf-8'));
custom.push({
name: file.replace('.json', ''),
displayName: preset.name,
description: preset.description
});
}
}
}
res.json({ builtIn, custom });
}
catch (err) {
debug('Error listing presets:', err);
res.status(500).json({ error: 'Failed to list presets' });
}
});
// GET /signalk/v1/unitpreferences/presets/:name
router.get('/presets/:name', (req, res) => {
try {
const presetName = req.params.name;
if (!VALID_PRESET_NAME.test(presetName)) {
res.status(400).json({ error: 'Invalid preset name' });
return;
}
// Check custom presets in config dir first
const customPath = path.join(configUnitprefsDir, 'presets/custom', `${presetName}.json`);
if (fs.existsSync(customPath)) {
const preset = JSON.parse(fs.readFileSync(customPath, 'utf-8'));
res.json(preset);
return;
}
// Fall back to built-in in package dir
const builtInPath = path.join(PACKAGE_UNITPREFS_DIR, 'presets', `${presetName}.json`);
if (fs.existsSync(builtInPath)) {
const preset = JSON.parse(fs.readFileSync(builtInPath, 'utf-8'));
res.json(preset);
return;
}
res.status(404).json({ error: 'Preset not found' });
}
catch (err) {
debug('Error getting preset:', err);
res.status(500).json({ error: 'Failed to get preset' });
}
});
// PUT /signalk/v1/unitpreferences/presets/custom/:name
router.put('/presets/custom/:name', (req, res) => {
try {
const presetName = req.params.name;
if (!VALID_PRESET_NAME.test(presetName)) {
res.status(400).json({ error: 'Invalid preset name' });
return;
}
// Prevent overwriting built-in presets
const builtInNames = ['metric', 'imperial-us', 'imperial-uk'];
if (builtInNames.includes(presetName)) {
res.status(400).json({ error: 'Cannot overwrite built-in preset' });
return;
}
// Validate preset structure
const definitions = getMergedDefinitions();
const validationError = validatePreset(req.body, definitions);
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
const customDir = path.join(configUnitprefsDir, 'presets/custom');
if (!fs.existsSync(customDir)) {
fs.mkdirSync(customDir, { recursive: true });
}
const presetPath = path.join(customDir, `${presetName}.json`);
atomicWriteFileSync(presetPath, JSON.stringify(req.body, null, 2));
invalidatePresetCache(presetName);
res.json({ success: true });
}
catch (err) {
debug('Error saving custom preset:', err);
res.status(500).json({ error: 'Failed to save custom preset' });
}
});
// DELETE /signalk/v1/unitpreferences/presets/custom/:name
router.delete('/presets/custom/:name', (req, res) => {
try {
const presetName = req.params.name;
if (!VALID_PRESET_NAME.test(presetName)) {
res.status(400).json({ error: 'Invalid preset name' });
return;
}
const presetPath = path.join(configUnitprefsDir, 'presets/custom', `${presetName}.json`);
if (!fs.existsSync(presetPath)) {
res.status(404).json({ error: 'Preset not found' });
return;
}
fs.unlinkSync(presetPath);
invalidatePresetCache(presetName);
res.json({ success: true });
}
catch (err) {
debug('Error deleting preset:', err);
res.status(500).json({ error: 'Failed to delete preset' });
}
});
// GET /signalk/v1/unitpreferences/active
router.get('/active', (req, res) => {
try {
const preset = getActivePreset();
const definitions = getMergedDefinitions();
const result = {
...preset,
categories: { ...preset.categories }
};
for (const [category, catDef] of Object.entries(result.categories)) {
const unitDef = definitions[catDef.baseUnit];
const conversion = unitDef?.conversions?.[catDef.targetUnit];
if (conversion) {
result.categories[category] = {
...catDef,
formula: conversion.formula,
inverseFormula: conversion.inverseFormula,
symbol: conversion.symbol
};
}
}
res.json(result);
}
catch (err) {
debug('Error getting active preset:', err);
res.status(500).json({ error: 'Failed to get active preset' });
}
});
// GET /signalk/v1/unitpreferences/default-categories
router.get('/default-categories', (req, res) => {
try {
const defaultCatPath = path.join(PACKAGE_UNITPREFS_DIR, 'default-categories.json');
if (fs.existsSync(defaultCatPath)) {
const data = JSON.parse(fs.readFileSync(defaultCatPath, 'utf-8'));
res.json(data);
}
else {
res.json({ categories: {} });
}
}
catch (err) {
debug('Error getting default categories:', err);
res.status(500).json({ error: 'Failed to get default categories' });
}
});
// GET /signalk/v1/unitpreferences/default-category/:path
router.get('/default-category/*', (req, res) => {
try {
const signalkPath = req.params[0];
const category = getDefaultCategory(signalkPath);
res.json({ path: signalkPath, category: category || null });
}
catch (err) {
debug('Error getting default category:', err);
res.status(500).json({ error: 'Failed to get default category' });
}
});
// POST /signalk/v1/unitpreferences/presets/custom/upload
const MAX_PRESET_SIZE = 100 * 1024; // 100KB
router.post('/presets/custom/upload', (req, res) => {
let responseSent = false;
const sendResponse = (status, body) => {
if (responseSent)
return;
responseSent = true;
res.status(status).json(body);
};
try {
const bb = busboy({
headers: req.headers,
limits: { fileSize: MAX_PRESET_SIZE }
});
let fileData = '';
let fileName = '';
let fileTruncated = false;
bb.on('file', (fieldname, file, info) => {
fileName = info.filename || '';
if (!fileName.endsWith('.json')) {
sendResponse(400, { error: 'File must have .json extension' });
file.resume();
return;
}
file.on('data', (data) => {
fileData += data.toString();
});
file.on('limit', () => {
fileTruncated = true;
});
});
bb.on('finish', () => {
if (responseSent)
return;
if (fileTruncated) {
sendResponse(400, {
error: `File too large. Maximum size is ${MAX_PRESET_SIZE / 1024}KB`
});
return;
}
if (!fileData) {
sendResponse(400, { error: 'No file uploaded' });
return;
}
// Parse JSON
let preset;
try {
preset = JSON.parse(fileData);
}
catch (e) {
sendResponse(400, { error: `Invalid JSON: ${e.message}` });
return;
}
// Validate preset structure
const definitions = getMergedDefinitions();
const validationError = validatePreset(preset, definitions);
if (validationError) {
sendResponse(400, { error: validationError });
return;
}
// Derive preset name from filename or preset.name
let presetName = fileName.replace('.json', '');
if (!/^[a-zA-Z0-9_-]+$/.test(presetName)) {
// Fall back to sanitized preset.name
presetName = preset.name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
}
// Check for existing preset with same name
const existing = checkPresetExists(presetName);
if (existing) {
if (existing.type === 'builtin') {
sendResponse(400, {
error: `Cannot overwrite built-in preset "${presetName}"`
});
return;
}
else {
sendResponse(409, {
error: `duplicate`,
existingName: presetName
});
return;
}
}
// Ensure custom directory exists
const customDir = path.join(configUnitprefsDir, 'presets/custom');
if (!fs.existsSync(customDir)) {
fs.mkdirSync(customDir, { recursive: true });
}
// Save the preset
const presetPath = path.join(customDir, `${presetName}.json`);
atomicWriteFileSync(presetPath, JSON.stringify(preset, null, 2));
invalidatePresetCache(presetName);
debug(`Custom preset uploaded: ${presetName}`);
sendResponse(200, {
success: true,
name: presetName,
displayName: preset.name,
isCustom: true
});
});
bb.on('error', (err) => {
debug('Error uploading preset:', err);
sendResponse(500, { error: 'Upload failed' });
});
req.pipe(bb);
}
catch (err) {
debug('Error in preset upload:', err);
sendResponse(500, { error: 'Failed to upload preset' });
}
});
// GET /signalk/v1/unitpreferences/primary-categories
router.get('/primary-categories', (req, res) => {
try {
const baseUnitMap = getBaseUnitToCategories();
const username = req.skPrincipal?.identifier;
const ambiguousUnits = {};
for (const [baseUnit, cats] of Object.entries(baseUnitMap)) {
if (cats.length > 1) {
ambiguousUnits[baseUnit] = cats;
}
}
const effectivePrimary = {};
for (const baseUnit of Object.keys(ambiguousUnits)) {
effectivePrimary[baseUnit] = getCategoryForBaseUnit(baseUnit, username);
}
res.json({ ambiguousUnits, effectivePrimary });
}
catch (err) {
debug('Error getting primary categories:', err);
res.status(500).json({ error: 'Failed to get primary categories' });
}
});
// PUT /signalk/v1/unitpreferences/primary-categories
router.put('/primary-categories', (req, res) => {
try {
const username = req.skPrincipal?.identifier;
if (!username) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (!req.body ||
typeof req.body !== 'object' ||
Array.isArray(req.body)) {
res.status(400).json({ error: 'Request body must be a JSON object' });
return;
}
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
const baseUnitMap = getBaseUnitToCategories();
const validated = {};
for (const [baseUnit, category] of Object.entries(req.body)) {
if (UNSAFE_KEYS.has(baseUnit)) {
res.status(400).json({ error: `Invalid key: ${baseUnit}` });
return;
}
if (typeof category !== 'string') {
res
.status(400)
.json({ error: `Value for "${baseUnit}" must be a string` });
return;
}
if (!baseUnitMap[baseUnit]) {
res.status(400).json({ error: `Unknown base unit: ${baseUnit}` });
return;
}
if (!baseUnitMap[baseUnit].includes(category)) {
res.status(400).json({
error: `Category "${category}" does not use base unit "${baseUnit}"`
});
return;
}
validated[baseUnit] = category;
}
const userPrefs = loadUserPreferences(username) || {};
userPrefs.primaryCategories = {
...userPrefs.primaryCategories,
...validated
};
saveUserPreferences(username, userPrefs);
app.emit('unitpreferencesChanged', { username });
res.json({ success: true });
}
catch (err) {
debug('Error saving primary categories:', err);
res.status(500).json({ error: 'Failed to save primary categories' });
}
});
return {
start: function () {
if (!app.securityStrategy.isDummy()) {
app.securityStrategy.addAdminWriteMiddleware('/signalk/v1/unitpreferences/config');
app.securityStrategy.addAdminWriteMiddleware('/signalk/v1/unitpreferences/custom-definitions');
app.securityStrategy.addAdminWriteMiddleware('/signalk/v1/unitpreferences/custom-categories');
app.securityStrategy.addAdminWriteMiddleware('/signalk/v1/unitpreferences/presets/custom/upload');
app.securityStrategy.addWriteMiddleware('/signalk/v1/unitpreferences/primary-categories');
// addAdminMiddleware protects all methods (including DELETE)
app.securityStrategy.addAdminMiddleware('/signalk/v1/unitpreferences/presets/custom/:name');
}
app.use('/signalk/v1/unitpreferences', router);
debug('Unit preferences API mounted at /signalk/v1/unitpreferences');
}
};
};
//# sourceMappingURL=unitpreferences-api.js.map