signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
284 lines • 11.5 kB
JavaScript
;
/*
* Copyright 2018 Scott Bender <scott@scottbender.net>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
Object.defineProperty(exports, "__esModule", { value: true });
const _ = require('lodash');
const atomicWrite_1 = require("../atomicWrite");
const debug_1 = require("../debug");
const debug = (0, debug_1.createDebug)('signalk-server:interfaces:applicationData');
const fs = require('fs');
const path = require('path');
const jsonpatch = require('json-patch');
const semver = require('semver');
const { saveUserPreferences } = require('../unitpreferences');
// User unit-preferences are versioned by the loader, not by the URL: the
// loader always reads the canonical file. Reject writes to any other version
// so clients don't silently land on disk where the loader will never look.
const UNITPREFS_APPID = 'unitpreferences';
const UNITPREFS_VERSION = '1.0.0';
const prefix = '/signalk/v1/applicationData';
// Per-file write lock to prevent concurrent read-modify-write races
const writeLocks = new Map();
function withFileLock(filePath, fn) {
const previous = writeLocks.get(filePath) || Promise.resolve();
const done = previous.then(fn, fn);
const cleanup = done.then(() => { }, () => { });
writeLocks.set(filePath, cleanup);
cleanup.then(() => {
if (writeLocks.get(filePath) === cleanup) {
writeLocks.delete(filePath);
}
});
return done;
}
const DANGEROUS_PATH_SEGMENTS = ['__proto__', 'constructor', 'prototype'];
function isPrototypePollutionPath(pathString) {
const segments = pathString.split(/[./]/);
return segments.some((seg) => DANGEROUS_PATH_SEGMENTS.includes(seg));
}
function hasPrototypePollutionPatch(patches) {
return patches.some((patch) => (patch.path && isPrototypePollutionPath(patch.path)) ||
(patch.from && isPrototypePollutionPath(patch.from)));
}
const applicationDataUrls = [
`${prefix}/global/:appid/:version/*`,
`${prefix}/global/:appid/:version`
];
const userApplicationDataUrls = [
`${prefix}/user/:appid/:version/*`,
`${prefix}/user/:appid/:version`
];
module.exports = function (app) {
if (app.securityStrategy.isDummy()) {
debug('ApplicationData disabled because security is off');
app.post(userApplicationDataUrls, (req, res) => {
res.status(405).send('security is not enabled');
});
app.post(applicationDataUrls, (req, res) => {
res.status(405).send('security is not enabled');
});
return;
}
applicationDataUrls.forEach((url) => {
app.securityStrategy.addAdminWriteMiddleware(url);
});
userApplicationDataUrls.forEach((url) => {
app.securityStrategy.addWriteMiddleware(url);
});
app.get(userApplicationDataUrls, (req, res) => {
getApplicationData(req, res, true);
});
app.post(userApplicationDataUrls, (req, res) => {
postApplicationData(req, res, true);
});
app.get(applicationDataUrls, (req, res) => {
getApplicationData(req, res, false);
});
app.post(applicationDataUrls, (req, res) => {
postApplicationData(req, res, false);
});
app.get(`${prefix}/global/:appid`, (req, res) => {
listVersions(req, res, false);
});
app.get(`${prefix}/user/:appid`, (req, res) => {
listVersions(req, res, true);
});
function listVersions(req, res, isUser) {
const appid = validateAppId(req.params.appid);
if (!appid) {
res.status(400).send('invalid application id');
return;
}
const dir = dirForApplicationData(req, appid, isUser);
if (!fs.existsSync(dir)) {
res.sendStatus(404);
return;
}
res.json(fs.readdirSync(dir).map((file) => file.slice(0, -5)));
}
function getApplicationData(req, res, isUser) {
const appid = validateAppId(req.params.appid);
const version = validateVersion(req.params.version);
if (!appid) {
res.status(400).send('invalid application id');
return;
}
if (!version) {
res.status(400).send('invalid application version');
return;
}
let applicationData = readApplicationData(req, appid, version, isUser);
let data = applicationData;
if (req.params[0] && req.params[0].length !== 0) {
data = _.get(applicationData, req.params[0].replace(/\//g, '.'));
if (!data) {
res.status(404).send();
return;
}
}
if (req.query.keys === 'true') {
if (typeof data !== 'object') {
res.status(404).send();
return;
}
data = _.keys(data);
}
res.json(data);
}
async function postApplicationData(req, res, isUser) {
const appid = validateAppId(req.params.appid);
const version = validateVersion(req.params.version);
if (!appid) {
res.status(400).send('invalid application id');
return;
}
if (!version) {
res.status(400).send('invalid application version');
return;
}
// Case-insensitive match: filesystems like APFS/NTFS would let a
// /UNITPREFERENCES write hit the same file as /unitpreferences.
const isUnitprefs = isUser && appid.toLowerCase() === UNITPREFS_APPID;
if (isUnitprefs && version !== UNITPREFS_VERSION) {
res
.status(400)
.send(`unit preferences only support version ${UNITPREFS_VERSION}`);
return;
}
const filePath = pathForApplicationData(req, isUnitprefs ? UNITPREFS_APPID : appid, version, isUser);
try {
let unitprefsChangedUsername = null;
await withFileLock(filePath, async () => {
let applicationData = readApplicationData(req, isUnitprefs ? UNITPREFS_APPID : appid, version, isUser);
if (req.params[0] && req.params[0].length !== 0) {
const dataPath = req.params[0].replace(/\//g, '.');
if (isPrototypePollutionPath(dataPath)) {
res.status(400).send('invalid path');
return;
}
_.set(applicationData, dataPath, req.body);
}
else if (_.isArray(req.body)) {
if (hasPrototypePollutionPatch(req.body)) {
res.status(400).send('invalid patch path');
return;
}
jsonpatch.apply(applicationData, req.body);
}
else {
applicationData = req.body;
}
if (isUnitprefs) {
// Route through saveUserPreferences so the loader's cache stays
// coherent (it sets the cache as part of the write).
saveUserPreferences(req.skPrincipal.identifier, applicationData);
unitprefsChangedUsername = req.skPrincipal.identifier;
}
else {
await saveApplicationData(req, appid, version, isUser, applicationData);
}
res.json('ApplicationData saved');
});
if (unitprefsChangedUsername) {
// Emit outside the lock: listeners can do per-spark fanout that we
// don't want serialized behind the file lock.
setImmediate(() => {
app.emit('unitpreferencesChanged', {
type: 'user',
username: unitprefsChangedUsername
});
});
}
}
catch (err) {
console.log(err);
res.status(500).send(err.message);
}
}
function readApplicationData(req, appid, version, isUser) {
let applicationDataString = '{}';
try {
applicationDataString = fs.readFileSync(pathForApplicationData(req, appid, version, isUser), 'utf8');
}
catch (_) {
debug('Could not find applicationData for %s %s', appid, version);
}
try {
const applicationData = JSON.parse(applicationDataString);
return applicationData;
}
catch (e) {
let filePath = dirForApplicationData(req, appid, isUser);
console.error('Could not parse applicationData for "%s": %s', filePath, e.message);
return {};
}
}
function validateAppId(appid) {
return appid.length < 30 &&
appid.indexOf('/') === -1 &&
appid.indexOf('\\') === -1
? appid
: null;
}
function validateVersion(version) {
return semver.valid(semver.coerce(version));
}
function dirForApplicationData(req, appid, isUser) {
let location = path.join(app.config.configPath, 'applicationData', isUser ? `users/${req.skPrincipal.identifier}` : 'global');
return path.join(location, appid);
}
function pathForApplicationData(req, appid, version, isUser) {
const filePath = path.normalize(path.join(dirForApplicationData(req, appid, isUser), `${version}.json`));
const configPath = path.resolve(app.config.configPath);
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(configPath)) {
throw new Error('Invalid path: outside configuration directory');
}
return filePath;
}
async function saveApplicationData(req, appid, version, isUser, data) {
const applicationDataDir = path.join(app.config.configPath, 'applicationData');
const usersDir = path.join(applicationDataDir, 'users');
const globalDir = path.join(applicationDataDir, 'global');
if (!fs.existsSync(applicationDataDir)) {
fs.mkdirSync(applicationDataDir);
}
if (isUser) {
if (!fs.existsSync(usersDir)) {
fs.mkdirSync(usersDir);
}
const userDir = path.join(usersDir, req.skPrincipal.identifier);
if (!fs.existsSync(userDir)) {
fs.mkdirSync(userDir);
}
const appDir = path.join(userDir, appid);
if (!fs.existsSync(appDir)) {
fs.mkdirSync(appDir);
}
}
else {
if (!fs.existsSync(globalDir)) {
fs.mkdirSync(globalDir);
}
const appDir = path.join(globalDir, appid);
if (!fs.existsSync(appDir)) {
fs.mkdirSync(appDir);
}
}
await (0, atomicWrite_1.atomicWriteFile)(pathForApplicationData(req, appid, version, isUser), JSON.stringify(data, null, 2));
}
};
//# sourceMappingURL=applicationData.js.map