tildefriends/core/core.js

774 lines
24 KiB
JavaScript
Raw Normal View History

import * as auth from './auth.js';
import * as app from './app.js';
import * as httpd from './httpd.js';
var gProcessIndex = 0;
var gProcesses = {};
var gStatsTimer = false;
var gGlobalSettings = {
index: "/~core/index",
};
var kGlobalSettingsFile = "data/global/settings.json";
var kPingInterval = 60 * 1000;
function printError(out, error) {
if (error.stackTrace) {
out.print(error.fileName + ":" + error.lineNumber + ": " + error.message);
out.print(error.stackTrace);
} else {
for (var i in error) {
out.print(i);
}
out.print(error.toString());
}
}
function invoke(handlers, argv) {
var promises = [];
if (handlers) {
for (var i = 0; i < handlers.length; ++i) {
try {
promises.push(handlers[i](...argv));
} catch (error) {
handlers.splice(i, 1);
i--;
promises.push(new Promise(function(resolve, reject) { reject(error); }));
}
}
}
return Promise.all(promises);
}
function broadcastEvent(eventName, argv) {
var promises = [];
for (var i in gProcesses) {
var process = gProcesses[i];
if (process.eventHandlers[eventName]) {
promises.push(invoke(process.eventHandlers[eventName], argv));
}
}
return Promise.all(promises);
}
function broadcast(message) {
var sender = this;
var promises = [];
for (var i in gProcesses) {
var process = gProcesses[i];
if (process != sender
&& process.packageOwner == sender.packageOwner
&& process.packageName == sender.packageName) {
var from = getUser(process, sender);
promises.push(postMessageInternal(from, process, message));
}
}
return Promise.all(promises);
}
function getUser(caller, process) {
return {
name: process.userName,
key: process.key,
index: process.index,
packageOwner: process.packageOwner,
packageName: process.packageName,
credentials: process.credentials,
postMessage: postMessageInternal.bind(caller, caller, process),
};
}
function getApps(user, process) {
if (process.credentials &&
process.credentials.session &&
process.credentials.session.name) {
if (user && user !== process.credentials.session.name && user !== 'core') {
return {};
} else if (!user) {
user = process.credentials.session.name;
}
}
if (user) {
var db = new Database(user);
try {
var names = JSON.parse(db.get('apps'));
return Object.fromEntries(names.map(name => [name, db.get('path:' + name)]));
} catch {
}
}
return {};
}
function postMessageInternal(from, to, message) {
if (to.eventHandlers['message']) {
return invoke(to.eventHandlers['message'], [getUser(from, from), message]);
}
}
async function getSessionProcessBlob(blobId, session, options) {
var actualOptions = {timeout: kPingInterval};
if (options) {
for (var i in options) {
actualOptions[i] = options[i];
}
}
return getProcessBlob(blobId, 'session_' + session, actualOptions);
}
let gManifestCache = {};
async function getProcessBlob(blobId, key, options) {
var process = gProcesses[key];
if (!process
&& !(options && "create" in options && !options.create)) {
try {
print("Creating task for " + blobId + " " + key);
process = {};
process.key = key;
process.index = gProcessIndex++;
process.userName = 'user' + process.index;
process.credentials = options.credentials || {};
process.task = new Task();
process.eventHandlers = {};
process.app = new app.App();
process.lastActive = Date.now();
process.lastPing = null;
process.timeout = options.timeout;
process.stats = false;
var resolveReady;
var rejectReady;
process.ready = new Promise(function(resolve, reject) {
resolveReady = resolve;
rejectReady = reject;
});
gProcesses[key] = process;
process.task.onExit = function(exitCode, terminationSignal) {
broadcastEvent('onSessionEnd', [getUser(process, process)]);
process.task = null;
delete gProcesses[key];
};
process.promises = {};
process.nextPromise = 1;
var imports = {
'core': {
'broadcast': broadcast.bind(process),
'register': function(eventName, handler) {
if (!process.eventHandlers[eventName]) {
process.eventHandlers[eventName] = [];
}
process.eventHandlers[eventName].push(handler);
},
'unregister': function(eventName, handler) {
if (process.eventHandlers[eventName]) {
let index = process.eventHandlers[eventName].indexOf(handler);
if (index != -1) {
process.eventHandlers[eventName].splice(index, 1);
}
if (process.eventHandlers[eventName].length == 0) {
delete process.eventHandlers[eventName];
}
}
},
'user': getUser(process, process),
'users': function() {
try {
return JSON.parse(new Database('auth').get('users'));
} catch {
return [];
}
},
'permissionsForUser': function(user) {
return (gGlobalSettings?.permissions ? gGlobalSettings.permissions[user] : []) ?? [];
},
'apps': user => getApps(user, process),
'getSockets': getSockets,
'permissionTest': function(permission) {
let id = process.nextPromise++;
let promise = new Promise(function(resolve, reject) {
process.promises[id] = {resolve: resolve, reject: reject};
});
let user = process?.credentials?.session?.name;
if (!user || !options?.packageOwner || !options?.packageName) {
process.promises[id].reject(false);
} else if (gGlobalSettings.userPermissions &&
gGlobalSettings.userPermissions[user] &&
gGlobalSettings.userPermissions[user][options.packageOwner] &&
gGlobalSettings.userPermissions[user][options.packageOwner][options.packageName] &&
gGlobalSettings.userPermissions[user][options.packageOwner][options.packageName][permission] !== undefined) {
if (gGlobalSettings.userPermissions[user][options.packageOwner][options.packageName][permission]) {
process.promises[id].resolve(true);
} else {
process.promises[id].reject(false);
}
} else {
process.app.send({action: 'requestPermission', permission: permission, id: id});
promise.then(function(value) {
if (value == 'allow') {
storePermission(user, options.packageOwner, options.packageName, permission, true);
return true;
} else if (value == 'allow once') {
return true;
}
return false;
}).catch(function(value) {
if (value == 'deny') {
storePermission(user, options.packageOwner, options.packageName, permission, false);
return false;
} else if (value == 'deny once') {
return false;
}
return false;
});
}
return promise;
},
}
};
if (process.credentials?.permissions?.administration) {
imports.core.deleteUser = function(user) {
return imports.core.permissionTest('delete_user').then(function() {
let db = new Database('auth');
db.remove('user:' + user);
let users = new Set();
let users_original = db.get('users');
try {
users = new Set(JSON.parse(users_original));
} catch {
}
users.delete(user);
users = JSON.stringify([...users].sort());
if (users !== users_original) {
db.set('users', users);
}
});
}
}
if (options.api) {
imports.app = {};
for (let i in options.api) {
let api = options.api[i];
imports.app[api[0]] = process.app.makeFunction(api);
}
}
process.task.onPrint = function(args) {
process.app.send({action: 'print', args: args});
};
process.task.onError = function(error) {
try {
process.app.send({action: 'error', error: error});
} catch(e) {
print(e);
}
};
imports.ssb = Object.fromEntries(Object.keys(ssb).map(key => [key, ssb[key].bind(ssb)]));
imports.ssb.createIdentity = function() {
if (process.credentials &&
process.credentials.session &&
process.credentials.session.name) {
return ssb.createIdentity(process.credentials.session.name);
}
};
imports.ssb.getIdentities = function() {
if (process.credentials &&
process.credentials.session &&
process.credentials.session.name) {
return ssb.getIdentities(process.credentials.session.name);
}
};
imports.ssb.appendMessageWithIdentity = function(id, message) {
if (process.credentials &&
process.credentials.session &&
process.credentials.session.name) {
return ssb.appendMessageWithIdentity(process.credentials.session.name, id, message);
}
};
delete imports.ssb.addRpc;
if (process.credentials &&
process.credentials.session &&
process.credentials.session.name) {
imports.database = function(key) {
var db = new Database(process.credentials.session.name + ':' + key);
return Object.fromEntries(Object.keys(db).map(x => [x, db[x].bind(db)]));
};
imports.my_shared_database = function(packageName, key) {
var db = new Database(':shared:' + process.credentials.session.name + ':' + packageName + ':' + key);
return Object.fromEntries(Object.keys(db).map(x => [x, db[x].bind(db)]));
};
imports.databases = function() {
return [].concat(databases.list(':shared:' + process.credentials.session.name + ':%'), databases.list(process.credentials.session.name + ':%'));
};
}
if (options.packageOwner && options.packageName) {
imports.shared_database = function(key) {
var db = new Database(':shared:' + options.packageOwner + ':' + options.packageName + ':' + key);
return Object.fromEntries(Object.keys(db).map(x => [x, db[x].bind(db)]));
}
}
process.task.setImports(imports);
process.task.activate();
let source = await getBlobOrContent(blobId);
var appSourceName = blobId;
var appSource = utf8Decode(source);
try {
var appObject = JSON.parse(appSource);
if (appObject.type == "tildefriends-app") {
appSourceName = 'app.js';
var id = appObject.files[appSourceName];
var blob = await getBlobOrContent(id);
appSource = utf8Decode(blob);
await process.task.loadFile(['/tfrpc.js', await File.readFile('core/tfrpc.js')]);
await Promise.all(Object.keys(appObject.files).map(async function(f) {
await process.task.loadFile([f, await getBlobOrContent(appObject.files[f])]);
}));
}
} catch (e) {
printError({print: print}, e);
}
broadcastEvent('onSessionBegin', [getUser(process, process)]);
resolveReady(process);
if (process.app) {
process.app.send({action: "ready"});
}
await process.task.execute({name: appSourceName, source: appSource});
} catch (error) {
if (process.app) {
process.app.send({action: 'error', error: error});
} else {
printError({print: print}, error);
}
rejectReady();
}
}
return process;
}
function setGlobalSettings(settings) {
gGlobalSettings = settings;
try {
return new Database('core').set('settings', JSON.stringify(settings));
} catch (error) {
print('Error storing settings:', error);
}
}
var kStaticFiles = [
{uri: '/', path: 'index.html', type: 'text/html; charset=UTF-8'},
{uri: '/style.css', type: 'text/css; charset=UTF-8'},
{uri: '/favicon.png', type: 'image/png'},
{uri: '/client.js', type: 'text/javascript; charset=UTF-8'},
{uri: '/tfrpc.js', type: 'text/javascript; charset=UTF-8', headers: {'Access-Control-Allow-Origin': 'null'}},
{uri: '/robots.txt', type: 'text/plain; charset=UTF-8'},
];
function startsWithBytes(data, bytes) {
if (data.byteLength >= bytes.length) {
var dataBytes = new Uint8Array(data.slice(0, bytes.length));
for (var i = 0; i < bytes.length; i++) {
if (dataBytes[i] != bytes[i] && bytes[i] !== null) {
return;
}
}
return true;
}
}
async function staticFileHandler(request, response, blobId, uri) {
for (var i in kStaticFiles) {
if (uri === kStaticFiles[i].uri) {
var path = kStaticFiles[i].path || uri.substring(1);
var type = kStaticFiles[i].type || guessType(path);
var data = await File.readFile("core/" + path);
response.writeHead(200, Object.assign({"Content-Type": type, "Content-Length": data.byteLength}, kStaticFiles[i].headers || {}));
response.end(data);
return;
}
}
response.writeHead(404, {"Content-Type": "text/plain; charset=utf-8", "Content-Length": "File not found".length});
response.end("File not found");
}
const k_mime_types = {
'json': 'text/json',
'js': 'text/javascript',
'html': 'text/html',
'css': 'text/css',
'map': 'application/json',
};
async function staticDirectoryHandler(request, response, directory, uri) {
var filename = uri || 'index.html';
if (filename.indexOf('..') != -1) {
response.writeHead(404, {"Content-Type": "text/plain; charset=utf-8", "Content-Length": "File not found".length});
response.end("File not found");
return;
}
try {
var data = await File.readFile(directory + filename);
response.writeHead(200, {"Content-Type": k_mime_types[filename.split('.').pop()] || 'text/plain', "Content-Length": data.byteLength});
response.end(data);
} catch {
response.writeHead(404, {"Content-Type": "text/plain; charset=utf-8", "Content-Length": "File not found".length});
response.end("File not found");
}
}
async function wellKnownHandler(request, response, path) {
var data = await File.readFile("data/global/.well-known/" + path);
if (data) {
response.writeHead(200, {"Content-Type": "text/plain", "Content-Length": data.length});
response.end(data);
} else {
response.writeHead(404, {"Content-Type": "text/plain", "Content-Length": "File not found".length});
response.end("File not found");
}
}
function sendData(response, data, type, headers) {
if (data) {
if (startsWithBytes(data, [0xff, 0xd8, 0xff, 0xdb]) ||
startsWithBytes(data, [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]) ||
startsWithBytes(data, [0xff, 0xd8, 0xff, 0xee]) ||
startsWithBytes(data, [0xff, 0xd8, 0xff, 0xe1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00])) {
response.writeHead(200, Object.assign({"Content-Type": "image/jpeg", "Content-Length": data.byteLength}, headers || {}));
response.end(data);
} else if (startsWithBytes(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
response.writeHead(200, Object.assign({"Content-Type": "image/png", "Content-Length": data.byteLength}, headers || {}));
response.end(data);
} else if (startsWithBytes(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
startsWithBytes(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) {
response.writeHead(200, Object.assign({"Content-Type": "image/gif", "Content-Length": data.byteLength}, headers || {}));
response.end(data);
} else if (startsWithBytes(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
response.writeHead(200, Object.assign({"Content-Type": "audio/mpeg", "Content-Length": data.byteLength}, headers || {}));
response.end(data);
} else {
response.writeHead(200, Object.assign({"Content-Type": type || "text/javascript; charset=utf-8", "Content-Length": data.byteLength}, headers || {}));
response.end(data);
}
} else {
response.writeHead(404, Object.assign({"Content-Type": "text/plain; charset=utf-8", "Content-Length": "File not found".length}, headers || {}));
response.end("File not found");
}
}
async function getBlobOrContent(id) {
if (!id) {
return;
} else if (id.startsWith('&')) {
return ssb.blobGet(id);
} else if (id.startsWith('%')) {
return ssb.messageContentGet(id);
}
}
function guessType(path) {
const k_extension_to_type = {
'css': 'text/css',
'html': 'text/html',
'js': 'text/javascript',
'svg': 'image/svg+xml',
};
var extension = path.split('.').pop();
return k_extension_to_type[extension];
}
async function blobHandler(request, response, blobId, uri) {
var found = false;
if (!found) {
for (var i in kStaticFiles) {
if (uri === kStaticFiles[i].uri && kStaticFiles[i].path) {
found = true;
var data = await File.readFile("core/" + kStaticFiles[i].path);
response.writeHead(200, {"Content-Type": kStaticFiles[i].type, "Content-Length": data.byteLength});
response.end(data);
break;
}
}
}
if (!uri) {
response.writeHead(303, {"Location": (request.client.tls ? 'https://' : 'http://') + request.headers.host + blobId + '/', "Content-Length": "0"});
response.end(data);
return;
}
if (!found) {
var process;
if (uri == "/view") {
var data;
if (match = /^\/\~(\w+)\/(\w+)$/.exec(blobId)) {
var id = await new Database(match[1]).get('path:' + match[2]);
if (id) {
if (request.headers['if-none-match'] && request.headers['if-none-match'] == '"' + id + '"') {
response.writeHead(304, {});
response.end();
} else {
data = await getBlobOrContent(id);
if (match[3]) {
var appObject = JSON.parse(data);
data = appObject.files[match[3]];
}
sendData(response, data, undefined, {etag: '"' + id + '"'});
}
} else {
sendData(response, data);
}
} else {
data = await getBlobOrContent(blobId);
sendData(response, data);
}
} else if (uri == "/save") {
let newBlobId = await ssb.blobStore(request.body);
var match;
if (match = /^\/\~(\w+)\/(\w+)$/.exec(blobId)) {
var user = match[1];
var appName = match[2];
var credentials = auth.query(request.headers);
if (credentials && credentials.session &&
(credentials.session.name == user ||
(credentials.permissions.administration && user == 'core'))) {
var database = new Database(user);
var apps = new Set();
let apps_original = database.get('apps');
try {
apps = new Set(JSON.parse(apps_original));
} catch {
}
if (!apps.has(appName)) {
apps.add(appName);
}
apps = JSON.stringify([...apps].sort());
if (apps != apps_original) {
database.set('apps', apps);
}
database.set('path:' + appName, newBlobId);
} else {
response.writeHead(401, {"Content-Type": "text/plain; charset=utf-8"});
response.end("401 Unauthorized");
return;
}
}
response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8"});
response.end("/" + newBlobId);
} else if (uri == "/delete") {
let match;
if (match = /^\/\~(\w+)\/(\w+)$/.exec(blobId)) {
var user = match[1];
var appName = match[2];
var credentials = auth.query(request.headers);
if (credentials && credentials.session &&
(credentials.session.name == user ||
(credentials.permissions.administration && user == 'core'))) {
var database = new Database(user);
var apps = new Set();
try {
apps = new Set(JSON.parse(database.get('apps')));
} catch {
}
if (apps.delete(appName)) {
database.set('apps', JSON.stringify([...apps]));
}
database.remove('path:' + appName);
} else {
response.writeHead(401, {"Content-Type": "text/plain; charset=utf-8"});
response.end("401 Unauthorized");
return;
}
}
response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8"});
response.end('OK');
} else {
var data;
var type;
var headers;
if (match = /^\/\~(\w+)\/(\w+)$/.exec(blobId)) {
var db = new Database(match[1]);
var id = await db.get('path:' + match[2]);
if (id) {
if (request.headers['if-none-match'] && request.headers['if-none-match'] == '"' + id + '"') {
headers = {
'Access-Control-Allow-Origin': '*',
};
response.writeHead(304, headers);
response.end();
} else {
data = utf8Decode(await getBlobOrContent(id));
var appObject = JSON.parse(data);
data = appObject.files[uri.substring(1)];
data = await getBlobOrContent(data);
type = guessType(uri);
headers = {
'ETag': '"' + id + '"',
'Access-Control-Allow-Origin': '*',
};
sendData(response, data, type, headers);
}
} else {
sendData(response, data, type, headers);
}
} else {
data = utf8Decode(await getBlobOrContent(blobId));
var appObject = JSON.parse(data);
data = appObject.files[uri.substring(1)];
data = await getBlobOrContent(data);
headers = {
'Access-Control-Allow-Origin': '*',
};
sendData(response, data, type, headers);
}
}
}
}
ssb.addEventListener('broadcasts', function() {
broadcastEvent('onBroadcastsChanged', []);
});
ssb.addEventListener('connections', function() {
broadcastEvent('onConnectionsChanged', []);
});
async function loadSettings() {
var data;
try {
var settings = new Database('core').get('settings');
if (settings) {
data = JSON.parse(settings);
}
} catch (error) {
print("Settings not found in database:", error);
}
if (!data) {
try {
data = JSON.parse(utf8Decode(await File.readFile(kGlobalSettingsFile)));
new Database('core').set('settings', JSON.stringify(data));
} catch (error) {
print("Unable to load settings from " + kGlobalSettingsFile + ":", error);
}
}
if (data) {
gGlobalSettings = data;
}
}
function sendStats() {
var any = false;
for (var process of Object.values(gProcesses)) {
if (process.app && process.stats) {
process.app.send({action: 'stats', stats: getStats()});
any = true;
}
}
if (any) {
setTimeout(sendStats, 1000);
} else {
gStatsTimer = false;
}
}
function enableStats(process, enabled) {
process.stats = enabled;
if (!gStatsTimer) {
gStatsTimer = true;
sendStats();
}
}
loadSettings().then(function() {
httpd.all("/login", auth.handler);
httpd.all("", function(request, response) {
var match;
if (request.uri === "/" || request.uri === "") {
response.writeHead(303, {"Location": (request.client.tls ? 'https://' : 'http://') + request.headers.host + gGlobalSettings.index, "Content-Length": "0"});
return response.end();
} else if (match = /^(\/~[^\/]+\/[^\/]+)(\/?.*)$/.exec(request.uri)) {
return blobHandler(request, response, match[1], match[2]);
} else if (match = /^\/([&\%][^\.]{44}(?:\.\w+)?)(\/?.*)/.exec(request.uri)) {
return blobHandler(request, response, match[1], match[2]);
} else if (match = /^\/static(\/.*)/.exec(request.uri)) {
return staticFileHandler(request, response, null, match[1]);
} else if (match = /^\/codemirror\/([\.\w-/]*)$/.exec(request.uri)) {
return staticDirectoryHandler(request, response, 'deps/codemirror/', match[1]);
} else if (match = /^\/perfetto\/([\.\w-/]*)$/.exec(request.uri)) {
return staticDirectoryHandler(request, response, 'deps/perfetto/', match[1]);
} else if (match = /^\/split\/([\.\w-/]*)$/.exec(request.uri)) {
return staticDirectoryHandler(request, response, 'deps/split/', match[1]);
} else if (match = /^\/smoothie\/([\.\w-/]*)$/.exec(request.uri)) {
return staticDirectoryHandler(request, response, 'deps/smoothie/', match[1]);
} else if (match = /^(.*)(\/(?:save|delete)?)$/.exec(request.uri)) {
return blobHandler(request, response, match[1], match[2]);
} else if (match = /^\/trace$/.exec(request.uri)) {
var data = trace();
response.writeHead(200, {"Content-Type": "application/json; charset=utf-8", "Content-Length": data.length.toString()});
return response.end(data);
} else if (request.uri == "/robots.txt") {
return blobHandler(request, response, null, request.uri);
} else if ((match = /^\/.well-known\/(.*)/.exec(request.uri)) && request.uri.indexOf("..") == -1) {
return wellKnownHandler(request, response, match[1]);
} else {
var data = "File not found.";
response.writeHead(404, {"Content-Type": "text/plain; charset=utf-8", "Content-Length": data.length.toString()});
return response.end(data);
}
});
httpd.registerSocketHandler("/app/socket", app.socket);
}).catch(function(error) {
print('Failed to load settings.');
printError({print: print}, error);
exit(1);
});
function setPermission(process, id, allow) {
if (process.promises[id]) {
if (allow == 'allow' || allow == 'allow once') {
process.promises[id].resolve(allow);
} else {
process.promises[id].reject(allow);
}
delete process.promises[id];
}
}
function storePermission(user, packageOwner, packageName, permission, allow) {
if (!gGlobalSettings.userPermissions) {
gGlobalSettings.userPermissions = {};
}
if (!gGlobalSettings.userPermissions[user]) {
gGlobalSettings.userPermissions[user] = {};
}
if (!gGlobalSettings.userPermissions[user][packageOwner]) {
gGlobalSettings.userPermissions[user][packageOwner] = {};
}
if (!gGlobalSettings.userPermissions[user][packageOwner][packageName]) {
gGlobalSettings.userPermissions[user][packageOwner][packageName] = {};
}
if (gGlobalSettings.userPermissions[user][packageOwner][packageName][permission] !== allow) {
gGlobalSettings.userPermissions[user][packageOwner][packageName][permission] = allow;
setGlobalSettings(gGlobalSettings);
}
}
export {
gGlobalSettings as globalSettings,
setGlobalSettings,
enableStats,
invoke,
getSessionProcessBlob,
setPermission,
};