import * as app from './app.js'; import * as form from './form.js'; import * as http from './http.js'; let gProcesses = {}; let gStatsTimer = false; const k_content_security_policy = 'sandbox allow-downloads allow-top-navigation-by-user-activation'; const k_global_settings = { index: { type: 'string', default_value: '/~core/apps/', description: 'Default path.', }, index_map: { type: 'textarea', default_value: undefined, description: 'Mappings from hostname to redirect path, one per line, as in: "www.tildefriends.net=/~core/index/"', }, room: { type: 'boolean', default_value: true, description: 'Enable peers to tunnel through this instance as a room.', }, room_name: { type: 'string', default_value: 'tilde friends tunnel', description: 'Name of the room.', }, replicator: { type: 'boolean', default_value: true, description: 'Enable message and blob replication.', }, code_of_conduct: { type: 'textarea', default_value: undefined, description: 'Code of conduct presented at sign-in.', }, http_redirect: { type: 'string', default_value: undefined, description: 'If connecting by HTTP and HTTPS is configured, Location header prefix (ie, "https://example.com")', }, fetch_hosts: { type: 'string', default_value: undefined, description: 'Comma-separated list of host names to which HTTP fetch requests are allowed. None if empty.', }, blob_fetch_age_seconds: { type: 'integer', default_value: platform() == 'android' || platform() == 'iphone' ? 0.5 * 365 * 24 * 60 * 60 : undefined, description: 'Only blobs mentioned more recently than this age will be automatically fetched.', }, blob_expire_age_seconds: { type: 'integer', default_value: platform() == 'android' || platform() == 'iphone' ? 1.0 * 365 * 24 * 60 * 60 : undefined, description: 'Blobs older than this will be automatically deleted.', }, seeds_host: { type: 'string', default_value: 'seeds.tildefriends.net', description: 'Hostname for seed connections.', }, peer_exchange: { type: 'boolean', default_value: false, description: 'Enable discovery of, sharing of, and connecting to internet peer strangers, including announcing this instance.', }, account_registration: { type: 'boolean', default_value: true, description: 'Allow registration of new accounts.', }, }; let kPingInterval = 60 * 1000; /** * TODOC * @param {*} out * @param {*} error */ function printError(out, error) { if (error.stackTrace) { out.print(error.fileName + ':' + error.lineNumber + ': ' + error.message); out.print(error.stackTrace); } else { for (let [k, v] of Object.entries(error)) { out.print(k, v); } out.print(error.toString()); } } /** * TODOC * @param {*} handlers * @param {*} argv * @returns */ function invoke(handlers, argv) { let promises = []; if (handlers) { for (let 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); } /** * TODOC * @param {*} eventName * @param {*} argv * @returns */ function broadcastEvent(eventName, argv) { let promises = []; for (let process of Object.values(gProcesses)) { if (process.eventHandlers[eventName]) { promises.push(invoke(process.eventHandlers[eventName], argv)); } } return Promise.all(promises); } /** * TODOC * @param {*} message * @returns */ function broadcast(message) { let sender = this; let promises = []; for (let process of Object.values(gProcesses)) { if ( process != sender && process.packageOwner == sender.packageOwner && process.packageName == sender.packageName ) { let from = getUser(process, sender); promises.push(postMessageInternal(from, process, message)); } } return Promise.all(promises); } /** * TODOC * @param {String} eventName * @param {*} argv * @returns */ function broadcastAppEventToUser( user, packageOwner, packageName, eventName, argv ) { let promises = []; for (let process of Object.values(gProcesses)) { if ( process.credentials?.session?.name === user && process.packageOwner == packageOwner && process.packageName == packageName ) { if (process.eventHandlers[eventName]) { promises.push(invoke(process.eventHandlers[eventName], argv)); } } } return Promise.all(promises); } /** * TODOC * @param {*} caller * @param {*} process * @returns */ function getUser(caller, process) { return { key: process.key, packageOwner: process.packageOwner, packageName: process.packageName, credentials: process.credentials, postMessage: postMessageInternal.bind(caller, caller, process), }; } /** * TODOC * @param {*} user * @param {*} process * @returns */ async 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) { let db = new Database(user); try { let names = JSON.parse(await db.get('apps')); let result = {}; for (let name of names) { result[name] = await db.get('path:' + name); } return result; } catch {} } return {}; } /** * TODOC * @param {*} from * @param {*} to * @param {*} message * @returns */ function postMessageInternal(from, to, message) { if (to.eventHandlers['message']) { return invoke(to.eventHandlers['message'], [getUser(from, from), message]); } } /** * TODOC * @param {*} blobId * @param {*} key * @param {*} options * @returns */ async function getProcessBlob(blobId, key, options) { // TODO(tasiaiso): break this down ? let process = gProcesses[key]; if (!process && !(options && 'create' in options && !options.create)) { let resolveReady; let rejectReady; try { print('Creating task for ' + blobId + ' ' + key); process = {}; process.key = key; process.credentials = options.credentials || {}; process.task = new Task(); process.packageOwner = options.packageOwner; process.packageName = options.packageName; process.eventHandlers = {}; if (!options?.script || options?.script === 'app.js') { process.app = new app.App(); } process.lastActive = Date.now(); process.lastPing = null; process.timeout = kPingInterval; 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]; }; let 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: async function () { try { return JSON.parse(await new Database('auth').get('users')); } catch { return []; } }, permissionsGranted: async function () { let user = process?.credentials?.session?.name; let settings = await loadSettings(); if ( user && options?.packageOwner && options?.packageName && settings.userPermissions && settings.userPermissions[user] && settings.userPermissions[user][options.packageOwner] ) { return settings.userPermissions[user][options.packageOwner][ options.packageName ]; } }, allPermissionsGranted: async function () { let user = process?.credentials?.session?.name; let settings = await loadSettings(); if ( user && options?.packageOwner && options?.packageName && settings.userPermissions && settings.userPermissions[user] ) { return settings.userPermissions[user]; } }, permissionsForUser: async function (user) { let settings = await loadSettings(); return settings?.permissions?.[user] ?? []; }, apps: (user) => getApps(user, process), getSockets: getSockets, permissionTest: async function (permission) { let user = process?.credentials?.session?.name; let settings = await loadSettings(); if (!user || !options?.packageOwner || !options?.packageName) { return; } else if ( settings.userPermissions && settings.userPermissions[user] && settings.userPermissions[user][options.packageOwner] && settings.userPermissions[user][options.packageOwner][ options.packageName ] && settings.userPermissions[user][options.packageOwner][ options.packageName ][permission] !== undefined ) { if ( settings.userPermissions[user][options.packageOwner][ options.packageName ][permission] ) { return true; } else { throw Error(`Permission denied: ${permission}.`); } } else if (process.app) { return process.app .makeFunction(['requestPermission'])(permission) .then(async function (value) { if (value == 'allow') { await ssb.setUserPermission( user, options.packageOwner, options.packageName, permission, true ); process.sendPermissions(); return true; } else if (value == 'allow once') { return true; } else if (value == 'deny') { await ssb.setUserPermission( user, options.packageOwner, options.packageName, permission, false ); process.sendPermissions(); throw Error(`Permission denied: ${permission}.`); } else if (value == 'deny once') { throw Error(`Permission denied: ${permission}.`); } throw Error(`Permission denied: ${permission}.`); }); } else { throw Error(`Permission denied: ${permission}.`); } }, app: { owner: options?.packageOwner, name: options?.packageName, }, url: options?.url, }, }; process.sendIdentities = async function () { process.app.send( Object.assign( { action: 'identities', }, await ssb.getIdentityInfo( process?.credentials?.session?.name, options?.packageOwner, options?.packageName ) ) ); }; process.setActiveIdentity = async function (identity) { if ( process?.credentials?.session?.name && options.packageOwner && options.packageName ) { await new Database(process?.credentials?.session?.name).set( `id:${options.packageOwner}:${options.packageName}`, identity ); } process.sendIdentities(); broadcastAppEventToUser( process?.credentials?.session?.name, options.packageOwner, options.packageName, 'setActiveIdentity', [identity] ); }; process.createIdentity = async function () { if ( process.credentials && process.credentials.session && process.credentials.session.name && process.credentials.session.name !== 'guest' ) { let id = await ssb.createIdentity(process.credentials.session.name); await process.sendIdentities(); broadcastAppEventToUser( process?.credentials?.session?.name, options.packageOwner, options.packageName, 'setActiveIdentity', [ await ssb.getActiveIdentity( process.credentials?.session?.name, options.packageOwner, options.packageName ), ] ); return id; } else { throw new Error('Must be signed-in to create an account.'); } }; if (process.credentials?.permissions?.administration) { imports.core.globalSettingsDescriptions = async function () { let settings = Object.assign({}, k_global_settings); for (let [key, value] of Object.entries(await loadSettings())) { if (settings[key]) { settings[key].value = value; } } return settings; }; imports.core.globalSettingsGet = async function (key) { let settings = await loadSettings(); return settings?.[key]; }; imports.core.globalSettingsSet = async function (key, value) { print('Setting', key, value); let settings = await loadSettings(); settings[key] = value; await new Database('core').set('settings', JSON.stringify(settings)); print('Done.'); }; imports.core.deleteUser = async function (user) { await imports.core.permissionTest('delete_user'); let db = new Database('auth'); db.remove('user:' + user); let users = new Set(); let users_original = await db.get('users'); try { users = new Set(JSON.parse(users_original)); } catch {} users.delete(user); users = JSON.stringify([...users].sort()); if (users !== users_original) { await 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); } } for (let [name, f] of Object.entries(options?.imports || {})) { imports[name] = f; } process.task.onPrint = function (args) { if (imports.app) { imports.app.print(...args); } }; process.task.onError = function (error) { try { if (process.app) { process.app.makeFunction(['error'])(error); } else { printError({print: print}, error); } } catch (e) { printError({print: print}, error); } }; imports.ssb = Object.fromEntries( Object.keys(ssb).map((key) => [key, ssb[key].bind(ssb)]) ); imports.ssb.port = tildefriends.ssb_port; imports.ssb.createIdentity = () => process.createIdentity(); imports.ssb.addIdentity = function (id) { if ( process.credentials && process.credentials.session && process.credentials.session.name ) { return Promise.resolve( imports.core.permissionTest('ssb_id_add') ).then(function () { return ssb.addIdentity(process.credentials.session.name, id); }); } }; imports.ssb.deleteIdentity = function (id) { if ( process.credentials && process.credentials.session && process.credentials.session.name ) { return Promise.resolve( imports.core.permissionTest('ssb_id_delete') ).then(function () { return ssb.deleteIdentity(process.credentials.session.name, id); }); } }; imports.ssb.setActiveIdentity = (id) => process.setActiveIdentity(id); imports.ssb.getActiveIdentity = () => ssb.getActiveIdentity( process.credentials?.session?.name, options.packageOwner, options.packageName ); imports.ssb.getOwnerIdentities = function () { if (options.packageOwner) { return ssb.getIdentities(options.packageOwner); } }; imports.ssb.getIdentities = function () { if ( process.credentials && process.credentials.session && process.credentials.session.name ) { return ssb.getIdentities(process.credentials.session.name); } }; imports.ssb.getPrivateKey = function (id) { if ( process.credentials && process.credentials.session && process.credentials.session.name ) { return Promise.resolve( imports.core.permissionTest('ssb_id_export') ).then(function () { return ssb.getPrivateKey(process.credentials.session.name, id); }); } }; imports.ssb.appendMessageWithIdentity = function (id, message) { if ( process.credentials && process.credentials.session && process.credentials.session.name ) { return Promise.resolve( imports.core.permissionTest('ssb_append') ).then(function () { return ssb.appendMessageWithIdentity( process.credentials.session.name, id, message ); }); } }; imports.ssb.privateMessageEncrypt = function (id, recipients, message) { if ( process.credentials && process.credentials.session && process.credentials.session.name ) { return ssb.privateMessageEncrypt( process.credentials.session.name, id, recipients, message ); } }; imports.ssb.privateMessageDecrypt = function (id, message) { if ( process.credentials && process.credentials.session && process.credentials.session.name ) { return ssb.privateMessageDecrypt( process.credentials.session.name, id, message ); } }; imports.ssb.setServerFollowingMe = function (id, following) { if ( process.credentials && process.credentials.session && process.credentials.session.name ) { return ssb.setServerFollowingMe( process.credentials.session.name, id, following ); } }; imports.ssb.swapWithServerIdentity = function (id) { if ( process.credentials && process.credentials.session && process.credentials.session.name ) { return ssb.swapWithServerIdentity( process.credentials.session.name, id ); } }; imports.ssb.addEventListener = undefined; imports.ssb.removeEventListener = undefined; imports.ssb.getIdentityInfo = undefined; imports.fetch = async function (url, options) { let settings = await loadSettings(); return http.fetch(url, options, settings?.fetch_hosts); }; if ( process.credentials && process.credentials.session && process.credentials.session.name ) { imports.database = function (key) { let 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) { let 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) { let db = new Database( ':shared:' + options.packageOwner + ':' + options.packageName + ':' + key ); return Object.fromEntries( Object.keys(db).map((x) => [x, db[x].bind(db)]) ); }; } process.sendPermissions = async function sendPermissions() { process.app.send({ action: 'permissions', permissions: await imports.core.permissionsGranted(), }); }; process.resetPermission = async function resetPermission(permission) { let user = process?.credentials?.session?.name; await ssb.setUserPermission( user, options?.packageOwner, options?.packageName, permission, undefined ); return process.sendPermissions(); }; process.task.setImports(imports); process.task.activate(); let source = await ssb.blobGet(blobId); let appSourceName = blobId; let appSource = utf8Decode(source); try { let appObject = JSON.parse(appSource); if (appObject.type == 'tildefriends-app') { appSourceName = options?.script ?? 'app.js'; let id = appObject.files[appSourceName]; let blob = await ssb.blobGet(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 ssb.blobGet(appObject.files[f]), ]); }) ); } } catch (e) { printError({print: print}, e); } broadcastEvent('onSessionBegin', [getUser(process, process)]); if (process.app) { process.app.send({action: 'ready', version: version()}); await process.sendPermissions(); } await process.task.execute({name: appSourceName, source: appSource}); resolveReady(process); if (!gStatsTimer) { gStatsTimer = true; sendStats(); } } catch (error) { if (process.app) { if (process?.task?.onError) { process.task.onError(error); } else { printError({print: print}, error); } } else { printError({print: print}, error); } rejectReady(error); } } return process; } /** * TODOC * @param {*} response * @param {*} data * @param {*} type * @param {*} headers * @param {*} status_code */ function sendData(response, data, type, headers, status_code) { if (data) { response.writeHead( status_code ?? 200, Object.assign( { 'Content-Type': type || httpd.mime_type_from_magic_bytes(data) || 'application/binary', 'Content-Length': data.byteLength, }, headers || {} ) ); response.end(data); } else { response.writeHead( status_code ?? 404, Object.assign( { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': 'File not found'.length, }, headers || {} ) ); response.end('File not found'); } } let g_handler_index = 0; /** * TODOC * @param {*} response * @param {*} handler_blob_id * @param {*} path * @param {*} query * @param {*} headers * @param {*} packageOwner * @param {*} packageName * @returns */ async function useAppHandler( response, handler_blob_id, path, query, headers, packageOwner, packageName ) { print('useAppHandler', packageOwner, packageName); let do_resolve; let promise = new Promise(async function (resolve, reject) { do_resolve = resolve; }); let process; let result; try { process = await getProcessBlob( handler_blob_id, 'handler_' + g_handler_index++, { script: 'handler.js', imports: { request: { path: path, query: query, }, respond: do_resolve, }, credentials: await httpd.auth_query(headers), packageOwner: packageOwner, packageName: packageName, } ); await process.ready; result = await promise; } finally { if (process?.task) { await process.task.kill(); } } return result; } /** * TODOC * @param {*} request * @param {*} response * @param {*} blobId * @param {*} uri * @returns */ async function blobHandler(request, response, blobId, uri) { if (!uri) { response.writeHead(303, { Location: (request.client.tls ? 'https://' : 'http://') + (request.headers['x-forwarded-host'] ?? request.headers.host) + blobId + '/', 'Content-Length': '0', }); response.end(); return; } let process; let data; let match; let id; let app_id = blobId; let packageOwner; let packageName; if ((match = /^\/\~(\w+)\/(\w+)$/.exec(blobId))) { packageOwner = match[1]; packageName = match[2]; let db = new Database(match[1]); app_id = await db.get('path:' + match[2]); } let app_object = JSON.parse(utf8Decode(await ssb.blobGet(app_id))); id = app_object?.files[uri.substring(1)]; if (!id && app_object?.files['handler.js']) { let answer; try { answer = await useAppHandler( response, app_id, uri.substring(1), request.query ? form.decodeForm(request.query) : undefined, request.headers, packageOwner, packageName ); } catch (error) { data = utf8Encode( `Internal Server Error\n\n${error?.message}\n${error?.stack}` ); response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': data.length, }); response.end(data); return; } if (answer && typeof answer.data == 'string') { answer.data = utf8Encode(answer.data); } sendData( response, answer?.data, answer?.content_type, Object.assign(answer?.headers ?? {}, { 'Access-Control-Allow-Origin': '*', 'Content-Security-Policy': k_content_security_policy, }), answer.status_code ); } else if (id) { if ( request.headers['if-none-match'] && request.headers['if-none-match'] == '"' + id + '"' ) { let headers = { 'Access-Control-Allow-Origin': '*', 'Content-Security-Policy': k_content_security_policy, 'Content-Length': '0', }; response.writeHead(304, headers); response.end(); } else { let headers = { ETag: '"' + id + '"', 'Access-Control-Allow-Origin': '*', 'Content-Security-Policy': k_content_security_policy, }; data = await ssb.blobGet(id); let type = httpd.mime_type_from_extension(uri) || httpd.mime_type_from_magic_bytes(data); sendData(response, data, type, headers); } } else { sendData(response, data, undefined, {}); } } ssb.addEventListener('message', function () { broadcastEvent('onMessage', [...arguments]); }); ssb.addEventListener('broadcasts', function () { broadcastEvent('onBroadcastsChanged', []); }); ssb.addEventListener('connections', function () { broadcastEvent('onConnectionsChanged', []); }); /** * TODOC */ async function loadSettings() { let data = {}; try { let settings = await new Database('core').get('settings'); if (settings) { data = JSON.parse(settings); } } catch (error) { print('Settings not found in database:', error); } for (let [key, value] of Object.entries(k_global_settings)) { if (data[key] === undefined) { data[key] = value.default_value; } } return data; } /** * TODOC */ function sendStats() { let apps = Object.values(gProcesses) .filter((process) => process.app) .map((process) => process.app); if (apps.length) { let stats = getStats(); for (let app of apps) { app.send({action: 'stats', stats: stats}); } setTimeout(sendStats, 1000); } else { gStatsTimer = false; } } /** * TODOC */ loadSettings() .then(function (settings) { if (tildefriends.https_port && settings.http_redirect) { httpd.set_http_redirect(settings.http_redirect); } httpd.all('/app/socket', app.socket); httpd.all('', function default_http_handler(request, response) { let match; 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]); } }); let port = httpd.start(tildefriends.http_port); if (tildefriends.args.out_http_port_file) { print('Writing the port file.'); File.writeFile( tildefriends.args.out_http_port_file, port.toString() + '\n' ) .then(function (r) { print( 'Wrote the port file:', tildefriends.args.out_http_port_file, r ); }) .catch(function () { print('Failed to write the port file.'); }); } if (tildefriends.https_port) { async function start_tls() { const kCertificatePath = 'data/httpd/certificate.pem'; const kPrivateKeyPath = 'data/httpd/privatekey.pem'; let privateKey; let certificate; try { privateKey = utf8Decode(await File.readFile(kPrivateKeyPath)); certificate = utf8Decode(await File.readFile(kCertificatePath)); } catch (e) { print(`TLS disabled (${e.message}).`); return; } let context = new TlsContext(); context.setPrivateKey(privateKey); context.setCertificate(certificate); httpd.start(tildefriends.https_port, context); } start_tls(); } }) .catch(function (error) { print('Failed to load settings.'); printError({print: print}, error); exit(1); }); export {invoke, getProcessBlob};