conextra
Version:
Conextra for Web Development. Helps create a SPWA (Single-page Progressive Web Application).
348 lines (253 loc) • 10.5 kB
JavaScript
'use script';
var { workerData } = require('node:worker_threads'),
{ isTLS, host, port, url, protocol } = workerData,
http = require("node:http"),
https = require("node:https"),
fs = require('node:fs'),
path = require('node:path'),
configSets = require('config-sets'),
options_server = configSets('tiny-https-server'),
conextraConfig = configSets('conextra', {
isDebug: false,
pathToResourseMap: 'data/resourse_map.json',
pathToDataDir: "data"
}),
CreateWsUser = require('ws-user'),
DataContext = require('data-context'),
wsList = [],
exitTimeout = null,
datacontext = null,
resourseMap = DataContext.watchJsonFile({
filePath: conextraConfig.pathToResourseMap,
onDataChange: function (event) {
if (!wsList.length || wsList[0].protocol !== 'ws-user') { return; }
wsList.forEach((ws) => {
resourceHandler.call(ws);
});
},
data: {
accessRoles: {
user: {
description: "User",
view: true
},
admin: {
description: "Administrator",
create: true,
view: true,
edit: true,
delete: true,
admin_create: true,
admin_view: true,
admin_edit: true,
admin_delete: true
}
},
"/public.json": {
description: "Public data",
organization: null,
permissions: ["public"]
},
"/logged_users.json": {
description: "Get the list of logged users",
organization: null,
permissions: ["admin_view"]
}
}
}),
server = (isTLS ? https : http)
.createServer(function (req, res) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found\n');
})
.on('upgrade', function (request, socket, head) {
var wsUser = CreateWsUser(request);
if (wsUser) {
clearTimeout(exitTimeout);
wsUser.onerror = pError;
wsUser.onclose = onClose;
if (wsUser.protocol === 'ws-user') {
wsUser.onopen = onOpenUser;
return;
}
if (wsUser.protocol === 'ws-link' && url.endsWith('.json')) {
wsUser.onopen = onOpenLink;
wsUser.onmessage = onMessageLink;
return;
}
}
pDebug('Unsupported websocket', request.headers);
socket.destroy();
})
.on('clientError', (err, socket) => { socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); })
.on('error', function (err) { pError(err); })
.listen({ port, host, exclusive: true }, function () {
pDebug('WebSocket-Worker server started', protocol + url, port);
});
if (isTLS && options_server.pathToCert) {
server.setSecureContext({
key: fs.readFileSync(options_server.pathToPrivkey),
cert: fs.readFileSync(options_server.pathToCert)
});
var certUpdateWaitTimeout;
fs.watch(path.dirname(options_server.pathToCert), function (eventType, filename) {
if (!fs.existsSync(options_server.pathToPrivkey) || !fs.existsSync(options_server.pathToCert)) { return; }
clearTimeout(certUpdateWaitTimeout);
certUpdateWaitTimeout = setTimeout(function () {
server.setSecureContext({
key: fs.readFileSync(options_server.pathToPrivkey),
cert: fs.readFileSync(options_server.pathToCert)
});
}, 1000);
});
}
if (url.endsWith('.json')) {
datacontext = DataContext.watchJsonFile({
filePath: path.join(conextraConfig.pathToDataDir, url),
onFileChange: function (event) {
if (event.strChanges === undefined) { return; }
pDebug('onFileChange', event.strChanges, url);
wsList.forEach((ws) => {
if (!hasPermission(['public', 'view', 'admin_view'], url, ws)) { return; }
ws.send(event.strChanges);
});
}
});
}
function onOpenUser() {
pDebug('WebSocket-Worker open', this.protocol, url);
this.extensionCommands.resources = resourceHandler;
this.onloggedin = resourcesUpdate.bind(this);
setTimeout(function (ws) {
resourcesUpdate.call(ws);
wsList.push(ws);
}, 0, this);
return;
function resourcesUpdate() {
resourceHandler.call(this);
}
}
function resourceHandler(event) {
if (event?.message) {
if (hasPermission(['admin_edit'], '/resource_map.json', this)) {
var obj = JSON.parse(event.message);
Object.keys(obj).forEach((key) => {
resourseMap[key] = obj[key];
});
}
event.done();
return;
}
clearTimeout(this.resourceHandlerTimeout);
this.resourceHandlerTimeout = setTimeout(function (ws) {
var resources = Object.keys(resourseMap)
.filter((key) => {
if (key === '/public.json') { return true; }
return hasPermission(resourseMap[key]?.permissions, key, ws);
})
.map((name) => { return { name, description: resourseMap[name].description }; });
var msg = '$resources:' + DataContext(resources).toString(true);
ws.send(msg);
}, 1000, this);
event?.done();
}
function onOpenLink() {
pDebug('open', this.protocol, this.connID);
if (!url.endsWith('.json')) { this.close(1008, 'Unsupported file type'); }
// TODO: [ ] hasPermission to continue open
if (!hasPermission(
['public', 'create', 'view', 'edit', 'delete', 'admin_create', 'admin_view', 'admin_edit', 'admin_delete'],
url, this)) {
this.close(1008, 'No permission');
return;
}
// TODO: [ ] hasPermission to continue send data
if (!hasPermission(['public', 'view', 'admin_view'], url, this)) { return; }
this.send(datacontext.toString(true));
setTimeout(function (ws) { wsList.push(ws); }, 0, this);
}
function onMessageLink(event) {
if (hasPermission(['public', 'edit', 'admin_edit'], url, this)) {
pDebug('message', this.protocol, event.data);
datacontext?.overwritingData(event.data);
broadcastChanges.call(this, event.data);
}
setTimeout(this.messageHandled);
}
function onClose(event) {
pDebug('WebSocket-Worker close', this.protocol, url);
// removing an WebSocket from list
wsList = wsList.filter(ws => ws !== this);
if (!wsList.length) {
exitTimeout = setTimeout(function () {
pDebug('WebSocket-Worker exit', url);
process.exit();
}, 30000);
}
}
function broadcastChanges(strChanges) {
if (!strChanges) { return; }
if (!this.protocol) { return; }
wsList.forEach((ws, i) => {
if (ws === this) { return; }
if (ws.readyState !== CreateWsUser.OPEN && ws.readyState !== CreateWsUser.PAUSE) { return; }
if (!hasPermission(['public', 'view', 'admin_view'], url, ws)) { return; }
pDebug('broadcastChanges', i, this.protocol, strChanges);
ws.send(strChanges);
});
}
/**
*
* @param {[string]} permissionActions
* @param {string} path
* @param {[string]} userRoles
* @param {[string]} organizations
* @returns {boolean}
*/
function hasPermission(permissionActions, path, user) {
//permissionActions -> ["view", "admin_view"]
//user.roles -> ["user", "organization.admin"]
//organizations -> ["organization"]
var permissions = resourseMap[path]?.permissions, //["admin_view", "view"]
organizations = resourseMap[path]?.organizations,
accessRoles = resourseMap.accessRoles; //{ user: { view: true }, admin: {...} }
if (!permissions) { return false; }
if (!Array.isArray(permissionActions)) { permissionActions = [permissionActions + '']; }
if (!Array.isArray(organizations)) { organizations = [organizations + '']; }
if (permissions?.includes('public') && permissionActions.includes('public')) { return true; }
if (!permissions?.length) { return false; }
if (!user.isLoggedIn) { return false; }
if (!user.roles?.length || !permissions?.length) { return false; }
if (!permissionActions.length) { return; }
//permissionActions -> ["view", "admin_view"]
//user.roles -> ["user", "organization.admin"]
//organizations -> ["organization"]
return user.roles.some(function (userRole) { // userRole -> "user"
for (var permissionAction of permissionActions) {
if (accessRoles?.[userRole]?.[permissionAction]
&& permissions.includes(permissionAction)) {
return true;
}
}
if (organizations.length) {
//userRole -> "organization.admin"
userRole = userRole.split('.');
var role = userRole.pop(),
accessOrganization = userRole.join('.')
if (accessOrganization) {
for (var organization of organizations) {
for (var permissionAction of permissionActions) {
if (accessOrganization === organization
&& accessRoles?.[role]?.[permissionAction]
&& permissions.includes(permissionAction)) {
return true;
}
}
}
}
}
});
}
// Debugging
function pDebug(...args) { if (conextraConfig.isDebug) { console.log(`[ DEBUG ] 'ws-user-link' `, ...args); } }
function pError(...args) { if (conextraConfig.isDebug) { console.error(`[ ERROR ] 'ws-user-link' `, ...args); } }